commit 5bd64741d98a03f96a4aa7df79ae9d4b68195300 Author: RunasSudo Date: Fri Sep 4 17:56:10 2020 +1000 Per Pretix 3.10 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8d35cb3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__ +*.pyc diff --git a/COPYING b/COPYING new file mode 100644 index 0000000..8dc880f --- /dev/null +++ b/COPYING @@ -0,0 +1,32 @@ +Copyright 2014-2016 Raphael Michel + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +This project includes the work of others, namely: + +* Django, (c) Django Software Foundation and contributors, BSD License +* Font Awesome, (c) Dave Gandy, SIL Open Font License and MIT License +* Bootstrap, (c) Twitter, Inc., MIT License +* jQuery, (c) jQuery Foundation and contributors, MIT License +* django-formset-js, (c) Ionata Web Solutions, BSD License +* CleanerVersion, (c) Jean-Christophe Zulian, Brian King, Andrea Marcacci, Manuel Jeckelmann, Apache License +* django-bootstrap3, (c) Dylan Verheul, Apache License +* pytz, (c) Stuart Bishop, MIT License +* python-dateutil, (c) Yaron de Leeuw, BSD License +* startbootstrap-sb-admin-2, (c) Iron Summit Media Strategies, LLC, Apache License +* metismenu, (c) Osman Nuri Okumus, MIT License +* easy-thumbnails, (c) Chris Beaven and contributors +* reportlab, (c) ReportLab Europe Ltd, BSD License +* django-compressor, (c) Jannis Leidel and contributors, MIT License +* static3, (c) Roman Mohr and contributors, LGPL License +* Lightbox, (c) Lokesh Dhakar, MIT License diff --git a/stripe/__init__.py b/stripe/__init__.py new file mode 100644 index 0000000..ac7218a --- /dev/null +++ b/stripe/__init__.py @@ -0,0 +1,33 @@ +from django.apps import AppConfig +from django.utils.functional import cached_property +from django.utils.translation import gettext_lazy as _ + +from pretix import __version__ as version + + +class StripeApp(AppConfig): + name = 'pretix.plugins.stripe' + verbose_name = _("Stripe") + + class PretixPluginMeta: + name = _("Stripe") + author = _("the pretix team") + version = version + category = 'PAYMENT' + description = _("This plugin allows you to receive credit card payments " + + "via Stripe") + + def ready(self): + from . import signals, tasks # NOQA + + @cached_property + def compatibility_errors(self): + errs = [] + try: + import stripe # NOQA + except ImportError: + errs.append("Python package 'stripe' is not installed.") + return errs + + +default_app_config = 'pretix.plugins.stripe.StripeApp' diff --git a/stripe/forms.py b/stripe/forms.py new file mode 100644 index 0000000..e94db52 --- /dev/null +++ b/stripe/forms.py @@ -0,0 +1,40 @@ +from django import forms +from django.utils.translation import gettext_lazy as _ + +from pretix.base.forms import SettingsForm + + +class StripeKeyValidator: + def __init__(self, prefix): + assert len(prefix) > 0 + if isinstance(prefix, list): + self._prefixes = prefix + else: + self._prefixes = [prefix] + assert isinstance(prefix, str) + + def __call__(self, value): + if not any(value.startswith(p) for p in self._prefixes): + raise forms.ValidationError( + _('The provided key "%(value)s" does not look valid. It should start with "%(prefix)s".'), + code='invalid-stripe-key', + params={ + 'value': value, + 'prefix': self._prefixes[0], + }, + ) + + +class OrganizerStripeSettingsForm(SettingsForm): + payment_stripe_connect_app_fee_percent = forms.DecimalField( + label=_('Stripe Connect: App fee (percent)'), + required=False, + ) + payment_stripe_connect_app_fee_max = forms.DecimalField( + label=_('Stripe Connect: App fee (max)'), + required=False, + ) + payment_stripe_connect_app_fee_min = forms.DecimalField( + label=_('Stripe Connect: App fee (min)'), + required=False, + ) diff --git a/stripe/management/__init__.py b/stripe/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/stripe/management/commands/__init__.py b/stripe/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/stripe/management/commands/stripe_connect_fill_countries.py b/stripe/management/commands/stripe_connect_fill_countries.py new file mode 100644 index 0000000..c60b554 --- /dev/null +++ b/stripe/management/commands/stripe_connect_fill_countries.py @@ -0,0 +1,35 @@ +import stripe +from django.core.management.base import BaseCommand +from django_scopes import scopes_disabled + +from pretix.base.models import Event +from pretix.base.settings import GlobalSettingsObject + + +class Command(BaseCommand): + help = "Detect country for Stripe Connect accounts connected with pretix 2.0 (required for payment request buttons)" + + @scopes_disabled() + def handle(self, *args, **options): + cache = {} + gs = GlobalSettingsObject() + api_key = gs.settings.payment_stripe_connect_secret_key or gs.settings.payment_stripe_connect_test_secret_key + if not api_key: + self.stderr.write(self.style.ERROR("Stripe Connect is not set up!")) + return + + for e in Event.objects.filter(plugins__icontains="pretix.plugins.stripe"): + uid = e.settings.payment_stripe_connect_user_id + if uid and not e.settings.payment_stripe_merchant_country: + if uid in cache: + e.settings.payment_stripe_merchant_country = cache[uid] + else: + try: + account = stripe.Account.retrieve( + uid, + api_key=api_key + ) + except Exception as e: + print(e) + else: + e.settings.payment_stripe_merchant_country = cache[uid] = account.get('country') diff --git a/stripe/migrations/0001_initial.py b/stripe/migrations/0001_initial.py new file mode 100644 index 0000000..b3dcdc6 --- /dev/null +++ b/stripe/migrations/0001_initial.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.3 on 2017-07-23 09:37 +from __future__ import unicode_literals + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('pretixbase', '0070_auto_20170719_0910'), + ] + + operations = [ + migrations.CreateModel( + name='ReferencedStripeObject', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('reference', models.CharField(db_index=True, max_length=190, unique=True)), + ('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Order')), + ], + ), + ] diff --git a/stripe/migrations/0002_referencedstripeobject_payment.py b/stripe/migrations/0002_referencedstripeobject_payment.py new file mode 100644 index 0000000..049d056 --- /dev/null +++ b/stripe/migrations/0002_referencedstripeobject_payment.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.13 on 2018-07-22 08:01 +from __future__ import unicode_literals + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0096_auto_20180722_0801'), + ('stripe', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='referencedstripeobject', + name='payment', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.OrderPayment'), + ), + ] diff --git a/stripe/migrations/0003_registeredapplepaydomain.py b/stripe/migrations/0003_registeredapplepaydomain.py new file mode 100644 index 0000000..c64fc01 --- /dev/null +++ b/stripe/migrations/0003_registeredapplepaydomain.py @@ -0,0 +1,21 @@ +# Generated by Django 2.1 on 2018-08-12 14:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('stripe', '0002_referencedstripeobject_payment'), + ] + + operations = [ + migrations.CreateModel( + name='RegisteredApplePayDomain', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('domain', models.CharField(max_length=190)), + ('account', models.CharField(max_length=190)), + ], + ), + ] diff --git a/stripe/migrations/__init__.py b/stripe/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/stripe/models.py b/stripe/models.py new file mode 100644 index 0000000..fea980b --- /dev/null +++ b/stripe/models.py @@ -0,0 +1,12 @@ +from django.db import models + + +class ReferencedStripeObject(models.Model): + reference = models.CharField(max_length=190, db_index=True, unique=True) + order = models.ForeignKey('pretixbase.Order', on_delete=models.CASCADE) + payment = models.ForeignKey('pretixbase.OrderPayment', null=True, blank=True, on_delete=models.CASCADE) + + +class RegisteredApplePayDomain(models.Model): + domain = models.CharField(max_length=190) + account = models.CharField(max_length=190) diff --git a/stripe/payment.py b/stripe/payment.py new file mode 100644 index 0000000..c2616d0 --- /dev/null +++ b/stripe/payment.py @@ -0,0 +1,1369 @@ +import hashlib +import json +import logging +import re +import urllib.parse +from collections import OrderedDict +from decimal import Decimal + +import stripe +from django import forms +from django.conf import settings +from django.contrib import messages +from django.core import signing +from django.db import transaction +from django.http import HttpRequest +from django.template.loader import get_template +from django.urls import reverse +from django.utils.crypto import get_random_string +from django.utils.safestring import mark_safe +from django.utils.timezone import now +from django.utils.translation import gettext, gettext_lazy as _, pgettext +from django_countries import countries + +from pretix import __version__ +from pretix.base.decimal import round_decimal +from pretix.base.models import Event, OrderPayment, OrderRefund, Quota +from pretix.base.payment import BasePaymentProvider, PaymentException +from pretix.base.plugins import get_all_plugins +from pretix.base.services.mail import SendMailException +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, eventreverse +from pretix.plugins.stripe.forms import StripeKeyValidator +from pretix.plugins.stripe.models import ( + ReferencedStripeObject, RegisteredApplePayDomain, +) +from pretix.plugins.stripe.tasks import ( + get_stripe_account_key, stripe_verify_domain, +) + +logger = logging.getLogger('pretix.plugins.stripe') + + +class StripeSettingsHolder(BasePaymentProvider): + identifier = 'stripe_settings' + verbose_name = _('Stripe') + is_enabled = False + is_meta = True + + def __init__(self, event: Event): + super().__init__(event) + self.settings = SettingsSandbox('payment', 'stripe', event) + + def get_connect_url(self, request): + 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 ( + "https://connect.stripe.com/oauth/authorize?response_type=code&client_id={}&state={}" + "&scope=read_write&redirect_uri={}" + ).format( + self.settings.connect_client_id, + request.session['payment_stripe_oauth_token'], + urllib.parse.quote(build_global_uri('plugins:stripe:oauth.return')), + ) + + def settings_content_render(self, request): + if self.settings.connect_client_id and not self.settings.secret_key: + # Use Stripe connect + if not self.settings.connect_user_id: + 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.get_connect_url(request), + _('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): + if 'pretix_resellers' in [p.module for p in get_all_plugins()]: + moto_settings = [ + ('reseller_moto', + forms.BooleanField( + label=_('Enable MOTO payments for resellers'), + help_text=( + _('Gated feature (needs to be enabled for your account by Stripe support first)') + + '
%s
' % _( + 'We can flag the credit card transaction you make through the reseller interface as MOTO ' + '(Mail Order / Telephone Order), which will exempt them from Strong Customer ' + 'Authentication (SCA) requirements. However: By enabling this feature, you will need to ' + 'fill out yearly PCI-DSS self-assessment forms like the 40 page SAQ D. Please consult the ' + '%s for further information on this subject.' % + '{}'.format( + _('Stripe Integration security guide') + ) + ) + ), + required=False, + )) + ] + else: + moto_settings = [] + + 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')), + ), + help_text=_('If your event is in test mode, we will always use Stripe\'s test API, ' + 'regardless of this setting.') + )), + ] + else: + return {} + else: + allcountries = list(countries) + allcountries.insert(0, ('', _('Select country'))) + + fields = [ + ('publishable_key', + forms.CharField( + label=_('Publishable key'), + help_text=_('{text}').format( + text=_('Click here for a tutorial on how to obtain the required keys'), + docs_url='https://docs.pretix.eu/en/latest/user/payments/stripe.html' + ), + validators=( + StripeKeyValidator('pk_'), + ), + )), + ('secret_key', + forms.CharField( + label=_('Secret key'), + validators=( + StripeKeyValidator(['sk_', 'rk_']), + ), + )), + ('merchant_country', + forms.ChoiceField( + choices=allcountries, + label=_('Merchant country'), + help_text=_('The country in which your Stripe-account is registered in. Usually, this is your ' + 'country of residence.'), + )), + ] + d = OrderedDict( + fields + [ + ('method_cc', + forms.BooleanField( + label=_('Credit card payments'), + required=False, + )), + ('method_giropay', + forms.BooleanField( + label=_('giropay'), + disabled=self.event.currency != 'EUR', + help_text=_('Needs to be enabled in your Stripe account first.'), + required=False, + )), + ('method_ideal', + forms.BooleanField( + label=_('iDEAL'), + disabled=self.event.currency != 'EUR', + help_text=_('Needs to be enabled in your Stripe account first.'), + required=False, + )), + ('method_alipay', + forms.BooleanField( + label=_('Alipay'), + disabled=self.event.currency not in ('EUR', 'AUD', 'CAD', 'GBP', 'HKD', 'JPY', 'NZD', 'SGD', 'USD'), + help_text=_('Needs to be enabled in your Stripe account first.'), + required=False, + )), + ('method_bancontact', + forms.BooleanField( + label=_('Bancontact'), + disabled=self.event.currency != 'EUR', + help_text=_('Needs to be enabled in your Stripe account first.'), + required=False, + )), + ('method_sofort', + forms.BooleanField( + label=_('SOFORT'), + disabled=self.event.currency != 'EUR', + help_text=( + _('Needs to be enabled in your Stripe account first.') + + '
%s
' % _( + 'Despite the name, Sofort 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, + )), + ('method_eps', + forms.BooleanField( + label=_('EPS'), + disabled=self.event.currency != 'EUR', + help_text=_('Needs to be enabled in your Stripe account first.'), + required=False, + )), + ('method_multibanco', + forms.BooleanField( + label=_('Multibanco'), + disabled=self.event.currency != 'EUR', + help_text=_('Needs to be enabled in your Stripe account first.'), + required=False, + )), + ('method_przelewy24', + forms.BooleanField( + label=_('Przelewy24'), + disabled=self.event.currency not in ['EUR', 'PLN'], + help_text=_('Needs to be enabled in your Stripe account first.'), + required=False, + )), + ('method_wechatpay', + forms.BooleanField( + label=_('WeChat Pay'), + disabled=self.event.currency not in ['AUD', 'CAD', 'EUR', 'GBP', 'HKD', 'JPY', 'SGD', 'USD'], + help_text=_('Needs to be enabled in your Stripe account first.'), + required=False, + )), + ] + list(super().settings_form_fields.items()) + moto_settings + ) + d.move_to_end('_enabled', last=False) + return d + + +class StripeMethod(BasePaymentProvider): + identifier = '' + method = '' + + def __init__(self, event: Event): + super().__init__(event) + self.settings = SettingsSandbox('payment', 'stripe', event) + + @property + def test_mode_message(self): + if self.settings.connect_client_id and not self.settings.secret_key: + is_testmode = True + else: + is_testmode = '_test_' in self.settings.secret_key + if is_testmode: + return mark_safe( + _('The Stripe plugin is operating in test mode. You can use one of many test ' + 'cards to perform a transaction. No money will actually be transferred.').format( + args='href="https://stripe.com/docs/testing#cards" target="_blank"' + ) + ) + return None + + @property + def settings_form_fields(self): + return {} + + @property + def is_enabled(self) -> bool: + return self.settings.get('_enabled', as_type=bool) and self.settings.get('method_{}'.format(self.method), + as_type=bool) + + def payment_refund_supported(self, payment: OrderPayment) -> bool: + return True + + def payment_partial_refund_supported(self, payment: OrderPayment) -> bool: + return True + + def payment_prepare(self, request, payment): + return self.checkout_prepare(request, None) + + def _amount_to_decimal(self, cents): + places = settings.CURRENCY_PLACES.get(self.event.currency, 2) + return round_decimal(float(cents) / (10 ** places), self.event.currency) + + def _decimal_to_int(self, amount): + places = settings.CURRENCY_PLACES.get(self.event.currency, 2) + return int(amount * 10 ** places) + + def _get_amount(self, payment): + return self._decimal_to_int(payment.amount) + + def _connect_kwargs(self, payment): + d = {} + if self.settings.connect_client_id and self.settings.connect_user_id: + fee = Decimal('0.00') + if self.settings.get('connect_app_fee_percent', as_type=Decimal): + fee = round_decimal(self.settings.get('connect_app_fee_percent', as_type=Decimal) * payment.amount / Decimal('100.00'), self.event.currency) + if self.settings.connect_app_fee_max: + fee = min(fee, self.settings.get('connect_app_fee_max', as_type=Decimal)) + if self.settings.get('connect_app_fee_min', as_type=Decimal): + fee = max(fee, self.settings.get('connect_app_fee_min', as_type=Decimal)) + if fee: + d['application_fee_amount'] = self._decimal_to_int(fee) + return d + + def statement_descriptor(self, payment, length=22): + return '{event}-{code} {eventname}'.format( + event=self.event.slug.upper(), + code=payment.order.code, + eventname=re.sub('[^a-zA-Z0-9 ]', '', str(self.event.name)) + )[:length] + + @property + def api_kwargs(self): + if self.settings.connect_client_id and self.settings.connect_user_id: + if self.settings.get('endpoint', 'live') == 'live' and not self.event.testmode: + 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 = '2019-05-16' + stripe.set_app_info( + "pretix", + partner_id="pp_partner_FSaz4PpKIur7Ox", + version=__version__, + url="https://pretix.eu" + ) + + def checkout_confirm_render(self, request) -> str: + template = get_template('pretixplugins/stripe/checkout_payment_confirm.html') + ctx = {'request': request, 'event': self.event, 'settings': self.settings, 'provider': self} + return template.render(ctx) + + def payment_can_retry(self, payment): + return self._is_still_available(order=payment.order) + + def _charge_source(self, request, source, payment): + try: + params = {} + if not source.startswith('src_'): + params['statement_descriptor'] = self.statement_descriptor(payment) + params.update(self.api_kwargs) + params.update(self._connect_kwargs(payment)) + charge = stripe.Charge.create( + amount=self._get_amount(payment), + currency=self.event.currency.lower(), + source=source, + description='{event}-{code}'.format( + event=self.event.slug.upper(), + code=payment.order.code + ), + 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 + source, + **params + ) + 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.fail(info={ + 'error': True, + '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 and 'error' in 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.fail(info={ + 'error': True, + '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=charge.id, + defaults={'order': payment.order, 'payment': payment} + ) + if charge.status == 'succeeded' and charge.paid: + try: + payment.info = str(charge) + payment.confirm() + except Quota.QuotaExceededException as e: + raise PaymentException(str(e)) + + except SendMailException: + raise PaymentException(_('There was an error sending the confirmation mail.')) + elif charge.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(charge) + payment.state = OrderPayment.PAYMENT_STATE_PENDING + payment.save() + return + else: + logger.info('Charge failed: %s' % str(charge)) + payment.fail(info=str(charge)) + raise PaymentException(_('Stripe reported an error: %s') % charge.failure_message) + + def payment_pending_render(self, request, payment) -> str: + if payment.info: + payment_info = json.loads(payment.info) + else: + payment_info = None + template = get_template('pretixplugins/stripe/pending.html') + ctx = { + 'request': request, + 'event': self.event, + 'settings': self.settings, + 'provider': self, + 'order': payment.order, + 'payment': payment, + 'payment_info': payment_info, + 'payment_hash': hashlib.sha1(payment.order.secret.lower().encode()).hexdigest() + } + return template.render(ctx) + + def matching_id(self, payment: OrderPayment): + return payment.info_data.get("id", None) + + def api_payment_details(self, payment: OrderPayment): + return { + "id": payment.info_data.get("id", None), + "payment_method": payment.info_data.get("payment_method", None) + } + + def payment_control_render(self, request, payment) -> str: + if payment.info: + payment_info = json.loads(payment.info) + if 'amount' in payment_info: + payment_info['amount'] /= 10 ** settings.CURRENCY_PLACES.get(self.event.currency, 2) + else: + payment_info = None + template = get_template('pretixplugins/stripe/control.html') + ctx = { + 'request': request, + 'event': self.event, + 'settings': self.settings, + 'payment_info': payment_info, + 'payment': payment, + 'method': self.method, + 'provider': self, + } + return template.render(ctx) + + @transaction.atomic() + def execute_refund(self, refund: OrderRefund): + self._init_api() + + payment_info = refund.payment.info_data + OrderPayment.objects.select_for_update().get(pk=refund.payment.pk) + + if not payment_info: + raise PaymentException(_('No payment information found.')) + + try: + if payment_info['id'].startswith('pi_'): + chargeid = payment_info['charges']['data'][0]['id'] + else: + chargeid = payment_info['id'] + + ch = stripe.Charge.retrieve(chargeid, **self.api_kwargs) + r = ch.refunds.create( + amount=self._get_amount(refund), + ) + ch.refresh() + except (stripe.error.InvalidRequestError, stripe.error.AuthenticationError, stripe.error.APIConnectionError) \ + as e: + if e.json_body and 'error' in 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)) + raise PaymentException(_('We had trouble communicating with Stripe. Please try again and contact ' + 'support if the problem persists.')) + except stripe.error.StripeError as err: + logger.error('Stripe error: %s' % str(err)) + raise PaymentException(_('Stripe returned an error')) + else: + refund.info = str(r) + if r.status in ('succeeded', 'pending'): + refund.done() + elif r.status in ('failed', 'canceled'): + refund.state = OrderRefund.REFUND_STATE_FAILED + refund.execution_date = now() + refund.save() + + def execute_payment(self, request: HttpRequest, payment: OrderPayment): + self._init_api() + try: + source = self._create_source(request, payment) + except stripe.error.StripeError as e: + if e.json_body and 'err' in 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.fail(info={ + 'error': True, + 'message': err['message'], + }) + raise PaymentException(_('We had trouble communicating with Stripe. Please try again and get in touch ' + 'with us if this problem persists.')) + + ReferencedStripeObject.objects.get_or_create( + reference=source.id, + defaults={'order': payment.order, 'payment': payment} + ) + payment.info = str(source) + payment.state = OrderPayment.PAYMENT_STATE_PENDING + payment.save() + request.session['payment_stripe_order_secret'] = payment.order.secret + return self.redirect(request, source.redirect.url) + + def redirect(self, request, url): + if request.session.get('iframe_session', False): + signer = signing.Signer(salt='safe-redirect') + return ( + build_absolute_uri(request.event, 'plugins:stripe:redirect') + '?url=' + + urllib.parse.quote(signer.sign(url)) + ) + else: + return str(url) + + def shred_payment_info(self, obj: OrderPayment): + if not obj.info: + return + d = json.loads(obj.info) + new = {} + if 'source' in d: + new['source'] = { + 'id': d['source'].get('id'), + 'type': d['source'].get('type'), + 'brand': d['source'].get('brand'), + 'last4': d['source'].get('last4'), + 'bank_name': d['source'].get('bank_name'), + 'bank': d['source'].get('bank'), + 'bic': d['source'].get('bic'), + 'card': { + 'brand': d['source'].get('card', {}).get('brand'), + 'country': d['source'].get('card', {}).get('cuntry'), + 'last4': d['source'].get('card', {}).get('last4'), + } + } + if 'amount' in d: + new['amount'] = d['amount'] + if 'currency' in d: + new['currency'] = d['currency'] + if 'status' in d: + new['status'] = d['status'] + if 'id' in d: + new['id'] = d['id'] + + new['_shredded'] = True + obj.info = json.dumps(new) + obj.save(update_fields=['info']) + + for le in obj.order.all_logentries().filter( + action_type="pretix.plugins.stripe.event" + ).exclude(data="", shredded=True): + d = le.parsed_data + if 'data' in d: + for k, v in list(d['data']['object'].items()): + if v not in ('reason', 'status', 'failure_message', 'object', 'id'): + d['data']['object'][k] = '█' + le.data = json.dumps(d) + le.shredded = True + le.save(update_fields=['data', 'shredded']) + + +class StripeCC(StripeMethod): + identifier = 'stripe' + verbose_name = _('Credit card via Stripe') + public_name = _('Credit card') + method = 'cc' + + 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) + + def payment_is_valid_session(self, request): + return request.session.get('payment_stripe_payment_method_id', '') != '' + + 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', '') + if payment_method_id == '': + messages.warning(request, _('You may need to enable JavaScript for Stripe payments.')) + return False + return True + + 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 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 _handle_payment_intent(self, request, payment, intent=None): + self._init_api() + + try: + if self.payment_is_valid_session(request): + params = {} + params.update(self._connect_kwargs(payment)) + params.update(self.api_kwargs) + + if self.is_moto(request, payment): + params.update({ + 'payment_method_options': { + 'card': { + 'moto': True + } + } + }) + + 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=self.statement_descriptor(payment), + 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(), + }), + **params + ) + 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: + 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.fail(info={ + 'error': True, + '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 and 'error' in 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.fail(info={ + 'error': True, + '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 == 'processing': + 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.fail(info=str(intent)) + return + else: + logger.info('Charge failed: %s' % str(intent)) + payment.fail(info=str(intent)) + raise PaymentException(_('Stripe reported an error: %s') % intent.last_payment_error.message) + + def _confirm_payment_intent(self, request, payment): + self._init_api() + + try: + 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.fail(info={ + 'error': True, + '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.fail(info={ + 'error': True, + 'message': err['message'], + }) + raise PaymentException(_('We had trouble communicating with Stripe. Please try again and get in touch ' + 'with us if this problem persists.')) + + +class StripeGiropay(StripeMethod): + identifier = 'stripe_giropay' + verbose_name = _('giropay via Stripe') + public_name = _('giropay') + method = 'giropay' + + def payment_form_render(self, request) -> str: + template = get_template('pretixplugins/stripe/checkout_payment_form_simple.html') + ctx = { + 'request': request, + 'event': self.event, + 'settings': self.settings, + 'form': self.payment_form(request) + } + return template.render(ctx) + + @property + def payment_form_fields(self): + return OrderedDict([ + ('account', forms.CharField(label=_('Account holder'))), + ]) + + def _create_source(self, request, payment): + try: + source = stripe.Source.create( + type='giropay', + amount=self._get_amount(payment), + currency=self.event.currency.lower(), + metadata={ + 'order': str(payment.order.id), + 'event': self.event.id, + 'code': payment.order.code + }, + owner={ + 'name': request.session.get('payment_stripe_giropay_account') or gettext('unknown name') + }, + statement_descriptor=self.statement_descriptor(payment, 35), + 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 + ) + return source + finally: + if 'payment_stripe_giropay_account' in request.session: + del request.session['payment_stripe_giropay_account'] + + def payment_is_valid_session(self, request): + return ( + request.session.get('payment_stripe_giropay_account', '') != '' + ) + + def checkout_prepare(self, request, cart): + form = self.payment_form(request) + if form.is_valid(): + request.session['payment_stripe_giropay_account'] = form.cleaned_data['account'] + return True + return False + + +class StripeIdeal(StripeMethod): + identifier = 'stripe_ideal' + verbose_name = _('iDEAL via Stripe') + public_name = _('iDEAL') + method = 'ideal' + + def payment_form_render(self, request) -> str: + template = get_template('pretixplugins/stripe/checkout_payment_form_simple_noform.html') + ctx = { + 'request': request, + 'event': self.event, + 'settings': self.settings, + } + return template.render(ctx) + + def _create_source(self, request, payment): + source = stripe.Source.create( + type='ideal', + amount=self._get_amount(payment), + currency=self.event.currency.lower(), + metadata={ + 'order': str(payment.order.id), + 'event': self.event.id, + 'code': payment.order.code + }, + statement_descriptor=self.statement_descriptor(payment), + 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 + ) + return source + + def payment_is_valid_session(self, request): + return True + + def checkout_prepare(self, request, cart): + return True + + +class StripeAlipay(StripeMethod): + identifier = 'stripe_alipay' + verbose_name = _('Alipay via Stripe') + public_name = _('Alipay') + method = 'alipay' + + def payment_form_render(self, request) -> str: + template = get_template('pretixplugins/stripe/checkout_payment_form_simple_noform.html') + ctx = { + 'request': request, + 'event': self.event, + 'settings': self.settings, + } + return template.render(ctx) + + def _create_source(self, request, payment): + source = stripe.Source.create( + type='alipay', + amount=self._get_amount(payment), + currency=self.event.currency.lower(), + 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 + ) + return source + + def payment_is_valid_session(self, request): + return True + + def checkout_prepare(self, request, cart): + return True + + +class StripeBancontact(StripeMethod): + identifier = 'stripe_bancontact' + verbose_name = _('Bancontact via Stripe') + public_name = _('Bancontact') + method = 'bancontact' + + def payment_form_render(self, request) -> str: + template = get_template('pretixplugins/stripe/checkout_payment_form_simple.html') + ctx = { + 'request': request, + 'event': self.event, + 'settings': self.settings, + 'form': self.payment_form(request) + } + return template.render(ctx) + + @property + def payment_form_fields(self): + return OrderedDict([ + ('account', forms.CharField(label=_('Account holder'), min_length=3)), + ]) + + def _create_source(self, request, payment): + try: + source = stripe.Source.create( + type='bancontact', + amount=self._get_amount(payment), + currency=self.event.currency.lower(), + metadata={ + 'order': str(payment.order.id), + 'event': self.event.id, + 'code': payment.order.code + }, + owner={ + 'name': request.session.get('payment_stripe_bancontact_account') or gettext('unknown name') + }, + statement_descriptor=self.statement_descriptor(payment, 35), + 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 + ) + return source + finally: + if 'payment_stripe_bancontact_account' in request.session: + del request.session['payment_stripe_bancontact_account'] + + def payment_is_valid_session(self, request): + return ( + request.session.get('payment_stripe_bancontact_account', '') != '' + ) + + def checkout_prepare(self, request, cart): + form = self.payment_form(request) + if form.is_valid(): + request.session['payment_stripe_bancontact_account'] = form.cleaned_data['account'] + return True + return False + + +class StripeSofort(StripeMethod): + identifier = 'stripe_sofort' + verbose_name = _('SOFORT via Stripe') + public_name = _('SOFORT') + method = 'sofort' + + def payment_form_render(self, request) -> str: + template = get_template('pretixplugins/stripe/checkout_payment_form_simple.html') + ctx = { + 'request': request, + 'event': self.event, + 'settings': self.settings, + 'form': self.payment_form(request) + } + return template.render(ctx) + + @property + def payment_form_fields(self): + return OrderedDict([ + ('bank_country', forms.ChoiceField(label=_('Country of your bank'), choices=( + ('de', _('Germany')), + ('at', _('Austria')), + ('be', _('Belgium')), + ('nl', _('Netherlands')), + ('es', _('Spain')) + ))), + ]) + + def _create_source(self, request, payment): + source = stripe.Source.create( + type='sofort', + amount=self._get_amount(payment), + currency=self.event.currency.lower(), + metadata={ + 'order': str(payment.order.id), + 'event': self.event.id, + 'code': payment.order.code + }, + statement_descriptor=self.statement_descriptor(payment, 35), + sofort={ + 'country': request.session.get('payment_stripe_sofort_bank_country'), + }, + 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 + ) + return source + + def payment_is_valid_session(self, request): + return ( + request.session.get('payment_stripe_sofort_bank_country', '') != '' + ) + + def checkout_prepare(self, request, cart): + form = self.payment_form(request) + if form.is_valid(): + request.session['payment_stripe_sofort_bank_country'] = form.cleaned_data['bank_country'] + return True + return False + + def payment_can_retry(self, payment): + return payment.state != OrderPayment.PAYMENT_STATE_PENDING and self._is_still_available(order=payment.order) + + +class StripeEPS(StripeMethod): + identifier = 'stripe_eps' + verbose_name = _('EPS via Stripe') + public_name = _('EPS') + method = 'eps' + + def payment_form_render(self, request) -> str: + template = get_template('pretixplugins/stripe/checkout_payment_form_simple.html') + ctx = { + 'request': request, + 'event': self.event, + 'settings': self.settings, + 'form': self.payment_form(request) + } + return template.render(ctx) + + @property + def payment_form_fields(self): + return OrderedDict([ + ('account', forms.CharField(label=_('Account holder'))), + ]) + + def _create_source(self, request, payment): + try: + source = stripe.Source.create( + type='eps', + amount=self._get_amount(payment), + currency=self.event.currency.lower(), + metadata={ + 'order': str(payment.order.id), + 'event': self.event.id, + 'code': payment.order.code + }, + owner={ + 'name': request.session.get('payment_stripe_eps_account') or gettext('unknown name') + }, + statement_descriptor=self.statement_descriptor(payment), + 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 + ) + return source + finally: + if 'payment_stripe_eps_account' in request.session: + del request.session['payment_stripe_eps_account'] + + def payment_is_valid_session(self, request): + return ( + request.session.get('payment_stripe_eps_account', '') != '' + ) + + def checkout_prepare(self, request, cart): + form = self.payment_form(request) + if form.is_valid(): + request.session['payment_stripe_eps_account'] = form.cleaned_data['account'] + return True + return False + + +class StripeMultibanco(StripeMethod): + identifier = 'stripe_multibanco' + verbose_name = _('Multibanco via Stripe') + public_name = _('Multibanco') + method = 'multibanco' + + def payment_form_render(self, request) -> str: + template = get_template('pretixplugins/stripe/checkout_payment_form_simple_noform.html') + ctx = { + 'request': request, + 'event': self.event, + 'settings': self.settings, + 'form': self.payment_form(request) + } + return template.render(ctx) + + def _create_source(self, request, payment): + source = stripe.Source.create( + type='multibanco', + amount=self._get_amount(payment), + currency=self.event.currency.lower(), + metadata={ + 'order': str(payment.order.id), + 'event': self.event.id, + 'code': payment.order.code + }, + owner={ + 'email': payment.order.email + }, + 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 + ) + return source + + def payment_is_valid_session(self, request): + return True + + def checkout_prepare(self, request, cart): + return True + + +class StripePrzelewy24(StripeMethod): + identifier = 'stripe_przelewy24' + verbose_name = _('Przelewy24 via Stripe') + public_name = _('Przelewy24') + method = 'przelewy24' + + def payment_form_render(self, request) -> str: + template = get_template('pretixplugins/stripe/checkout_payment_form_simple_noform.html') + ctx = { + 'request': request, + 'event': self.event, + 'settings': self.settings, + 'form': self.payment_form(request) + } + return template.render(ctx) + + def _create_source(self, request, payment): + source = stripe.Source.create( + type='p24', + amount=self._get_amount(payment), + currency=self.event.currency.lower(), + metadata={ + 'order': str(payment.order.id), + 'event': self.event.id, + 'code': payment.order.code + }, + owner={ + 'email': payment.order.email + }, + statement_descriptor=self.statement_descriptor(payment, 35), + 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 + ) + return source + + def payment_is_valid_session(self, request): + return True + + def checkout_prepare(self, request, cart): + return True + + +class StripeWeChatPay(StripeMethod): + identifier = 'stripe_wechatpay' + verbose_name = _('WeChat Pay via Stripe') + public_name = _('WeChat Pay') + method = 'wechatpay' + + def payment_form_render(self, request) -> str: + template = get_template('pretixplugins/stripe/checkout_payment_form_simple_noform.html') + ctx = { + 'request': request, + 'event': self.event, + 'settings': self.settings, + 'form': self.payment_form(request) + } + return template.render(ctx) + + def _create_source(self, request, payment): + source = stripe.Source.create( + type='wechat', + amount=self._get_amount(payment), + currency=self.event.currency.lower(), + metadata={ + 'order': str(payment.order.id), + 'event': self.event.id, + 'code': payment.order.code + }, + statement_descriptor=self.statement_descriptor(payment, 32), + 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 + ) + return source + + def payment_is_valid_session(self, request): + return True + + def checkout_prepare(self, request, cart): + return True + + def execute_payment(self, request: HttpRequest, payment: OrderPayment): + self._init_api() + try: + source = self._create_source(request, payment) + except stripe.error.StripeError as e: + if e.json_body and 'err' in 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.fail(info={ + 'error': True, + 'message': err['message'], + }) + raise PaymentException(_('We had trouble communicating with Stripe. Please try again and get in touch ' + 'with us if this problem persists.')) + + ReferencedStripeObject.objects.get_or_create( + reference=source.id, + defaults={'order': payment.order, 'payment': payment} + ) + payment.info = str(source) + payment.save() + + return eventreverse(request.event, 'presale:event.order', kwargs={ + 'order': payment.order.code, + 'secret': payment.order.secret + }) diff --git a/stripe/signals.py b/stripe/signals.py new file mode 100644 index 0000000..b4a94b9 --- /dev/null +++ b/stripe/signals.py @@ -0,0 +1,178 @@ +import json +from collections import OrderedDict + +from django import forms +from django.dispatch import receiver +from django.template.loader import get_template +from django.urls import resolve, reverse +from django.utils.translation import gettext_lazy as _ + +from pretix.base.forms import SecretKeySettingsField +from pretix.base.settings import settings_hierarkey +from pretix.base.signals import ( + logentry_display, register_global_settings, register_payment_providers, + requiredaction_display, +) +from pretix.control.signals import nav_organizer +from pretix.plugins.stripe.forms import StripeKeyValidator +from pretix.presale.signals import html_head + + +@receiver(register_payment_providers, dispatch_uid="payment_stripe") +def register_payment_provider(sender, **kwargs): + from .payment import ( + StripeSettingsHolder, StripeCC, StripeGiropay, StripeIdeal, StripeAlipay, StripeBancontact, + StripeSofort, StripeEPS, StripeMultibanco, StripePrzelewy24, StripeWeChatPay + ) + + return [ + StripeSettingsHolder, StripeCC, StripeGiropay, StripeIdeal, StripeAlipay, StripeBancontact, + StripeSofort, StripeEPS, StripeMultibanco, StripePrzelewy24, StripeWeChatPay + ] + + +@receiver(html_head, dispatch_uid="payment_stripe_html_head") +def html_head_presale(sender, request=None, **kwargs): + from .payment import StripeSettingsHolder + + provider = StripeSettingsHolder(sender) + url = resolve(request.path_info) + if provider.settings.get('_enabled', as_type=bool) and ("checkout" in url.url_name or "order.pay" in url.url_name): + template = get_template('pretixplugins/stripe/presale_head.html') + ctx = { + 'event': sender, + 'settings': provider.settings, + 'testmode': ( + (provider.settings.get('endpoint', 'live') == 'test' or sender.testmode) + and provider.settings.publishable_test_key + ) + } + return template.render(ctx) + else: + return "" + + +@receiver(signal=logentry_display, dispatch_uid="stripe_logentry_display") +def pretixcontrol_logentry_display(sender, logentry, **kwargs): + if logentry.action_type != 'pretix.plugins.stripe.event': + return + + data = json.loads(logentry.data) + event_type = data.get('type') + text = None + plains = { + 'charge.succeeded': _('Charge succeeded.'), + 'charge.refunded': _('Charge refunded.'), + 'charge.updated': _('Charge updated.'), + 'charge.pending': _('Charge pending'), + 'source.chargeable': _('Payment authorized.'), + 'source.canceled': _('Payment authorization canceled.'), + 'source.failed': _('Payment authorization failed.') + } + + if event_type in plains: + text = plains[event_type] + elif event_type == 'charge.failed': + text = _('Charge failed. Reason: {}').format(data['data']['object']['failure_message']) + elif event_type == 'charge.dispute.created': + text = _('Dispute created. Reason: {}').format(data['data']['object']['reason']) + elif event_type == 'charge.dispute.updated': + text = _('Dispute updated. Reason: {}').format(data['data']['object']['reason']) + elif event_type == 'charge.dispute.closed': + text = _('Dispute closed. Status: {}').format(data['data']['object']['status']) + + if text: + return _('Stripe reported an event: {}').format(text) + + +settings_hierarkey.add_default('payment_stripe_method_cc', True, bool) +settings_hierarkey.add_default('payment_stripe_reseller_moto', False, 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', SecretKeySettingsField( + label=_('Stripe Connect: Secret key'), + required=False, + validators=( + StripeKeyValidator(['sk_live_', 'rk_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', SecretKeySettingsField( + label=_('Stripe Connect: Secret key (test)'), + required=False, + validators=( + StripeKeyValidator(['sk_test_', 'rk_test_']), + ), + )), + ('payment_stripe_connect_test_publishable_key', forms.CharField( + label=_('Stripe Connect: Publishable key (test)'), + required=False, + validators=( + StripeKeyValidator('pk_test_'), + ), + )), + ('payment_stripe_connect_app_fee_percent', forms.DecimalField( + label=_('Stripe Connect: App fee (percent)'), + required=False, + )), + ('payment_stripe_connect_app_fee_max', forms.DecimalField( + label=_('Stripe Connect: App fee (max)'), + required=False, + )), + ('payment_stripe_connect_app_fee_min', forms.DecimalField( + label=_('Stripe Connect: App fee (min)'), + required=False, + )), + ]) + + +@receiver(signal=requiredaction_display, dispatch_uid="stripe_requiredaction_display") +def pretixcontrol_action_display(sender, action, request, **kwargs): + # DEPRECATED + if not action.action_type.startswith('pretix.plugins.stripe'): + return + + data = json.loads(action.data) + + if action.action_type == 'pretix.plugins.stripe.refund': + template = get_template('pretixplugins/stripe/action_refund.html') + elif action.action_type == 'pretix.plugins.stripe.overpaid': + template = get_template('pretixplugins/stripe/action_overpaid.html') + elif action.action_type == 'pretix.plugins.stripe.double': + template = get_template('pretixplugins/stripe/action_double.html') + + ctx = {'data': data, 'event': sender, 'action': action} + return template.render(ctx, request) + + +@receiver(nav_organizer, dispatch_uid="stripe_nav_organizer") +def nav_o(sender, request, organizer, **kwargs): + if request.user.has_active_staff_session(request.session.session_key): + url = resolve(request.path_info) + return [{ + 'label': _('Stripe Connect'), + 'url': reverse('plugins:stripe:settings.connect', kwargs={ + 'organizer': request.organizer.slug + }), + 'parent': reverse('control:organizer.edit', kwargs={ + 'organizer': request.organizer.slug + }), + 'active': 'settings.connect' in url.url_name, + }] + return [] diff --git a/stripe/static/pretixplugins/stripe/pretix-stripe.css b/stripe/static/pretixplugins/stripe/pretix-stripe.css new file mode 100644 index 0000000..c9e8a9f --- /dev/null +++ b/stripe/static/pretixplugins/stripe/pretix-stripe.css @@ -0,0 +1,81 @@ +.sep { +} + +.sepText { + width: 75px; + background: #FFFFFF; + margin: -15px 0 0 -38px; + padding: 5px 0; + position: absolute; + top: 50%; + text-align: center; +} + +.hr { + width:2px; + height:64px; + background-color: #DDDDDD; + position:inherit; + top:0px; + left:50%; + z-index:10; +} +#stripe-card { + margin: 15px 0; +} + +.embed-responsive-sca { + padding-bottom: 75%; + min-height: 600px; +} + +@media only screen and (max-width: 999px) { + .hr { + width: 100%; + height: 2px; + left: 0px; + margin: 15px 0 15px 0; + } + .sepText { + left: 50%; + } + #stripe-elements > div.hidden { + height: 0; + padding-top: 0; + padding-bottom: 0; + overflow: hidden; + display: block !important; + } + #stripe-elements .stripe-or { + height: 16px; + } + #stripe-elements .stripe-payment-request-button { + height: 40px; + } + #stripe-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 { + display: flex; + flex-wrap: wrap; + } + .stripe-card-holder { + flex-grow: 1; + } + #stripe-elements > div.hidden { + width: 0; + padding: 0; + overflow: hidden; + display: block !important; + } + #stripe-elements > div { + transition: width 0.3s ease-out, padding-left 0.3s ease-out, padding-right 0.3s ease-out; + } +} + +.vcenter { + margin: auto; +} diff --git a/stripe/static/pretixplugins/stripe/pretix-stripe.js b/stripe/static/pretixplugins/stripe/pretix-stripe.js new file mode 100644 index 0000000..709d8d3 --- /dev/null +++ b/stripe/static/pretixplugins/stripe/pretix-stripe.js @@ -0,0 +1,229 @@ +/*global $, stripe_pubkey, stripe_loadingmessage, gettext */ +'use strict'; + +var pretixstripe = { + stripe: null, + elements: null, + card: null, + paymentRequest: null, + paymentRequestButton: null, + + 'cc_request': function () { + waitingDialog.show(gettext("Contacting Stripe …")); + $(".stripe-errors").hide(); + + // 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_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); + // and submit + $form.get(0).submit(); + } + }); + }, + 'load': function () { + 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/', + dataType: 'script', + success: function () { + if ($.trim($("#stripe_connectedAccountId").html())) { + pretixstripe.stripe = Stripe($.trim($("#stripe_pubkey").html()), { + stripeAccount: $.trim($("#stripe_connectedAccountId").html()) + }); + } else { + pretixstripe.stripe = Stripe($.trim($("#stripe_pubkey").html())); + } + pretixstripe.elements = pretixstripe.stripe.elements(); + if ($.trim($("#stripe_merchantcountry").html()) !== "") { + try { + pretixstripe.paymentRequest = pretixstripe.stripe.paymentRequest({ + country: $("#stripe_merchantcountry").html(), + currency: $("#stripe_currency").val().toLowerCase(), + total: { + label: gettext('Total'), + amount: parseInt($("#stripe_total").val()) + }, + displayItems: [], + requestPayerName: false, + requestPayerEmail: false, + requestPayerPhone: false, + requestShipping: false, + }); + + pretixstripe.paymentRequest.on('paymentmethod', function (ev) { + ev.complete('success'); + + var $form = $("#stripe_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_brand").val(ev.paymentMethod.card.brand); + $("#stripe_card_last4").val(ev.paymentMethod.card.last4); + // and submit + $form.get(0).submit(); + }); + } catch (e) { + pretixstripe.paymentRequest = null; + } + } else { + pretixstripe.paymentRequest = null; + } + if ($("#stripe-card").length) { + pretixstripe.card = pretixstripe.elements.create('card', { + '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', + }, + }, + classes: { + focus: 'is-focused', + invalid: 'has-error', + } + }); + 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-payment-request-button").length && pretixstripe.paymentRequest != null) { + 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'; + } + }); + } + } + } + ); + }, + '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()) + }); + } 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(); + }); + } + }); + }, + '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_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) { + pretixstripe.load(); + } else { + $("input[name=payment]").change(function () { + if ($(this).val() === 'stripe') { + pretixstripe.load(); + } + }) + } + + $("#stripe_other_card").click( + function (e) { + $("#stripe_payment_method_id").val(""); + $("#stripe-current-card").slideUp(); + $("#stripe-elements").slideDown(); + + e.preventDefault(); + return false; + } + ); + + if ($("#stripe-current-card").length) { + $("#stripe-elements").hide(); + } + + $('.stripe-container').closest("form").submit( + function () { + if ($("input[name=card_new]").length && !$("input[name=card_new]").prop('checked')) { + 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(); + return false; + } + } + ); +}); \ No newline at end of file diff --git a/stripe/tasks.py b/stripe/tasks.py new file mode 100644 index 0000000..f7260de --- /dev/null +++ b/stripe/tasks.py @@ -0,0 +1,51 @@ +import logging +from urllib.parse import urlsplit + +import stripe +from django.conf import settings + +from pretix.base.services.tasks import EventTask +from pretix.celery_app import app +from pretix.multidomain.urlreverse import get_event_domain +from pretix.plugins.stripe.models import RegisteredApplePayDomain + +logger = logging.getLogger(__name__) + + +def get_domain_for_event(event): + domain = get_event_domain(event, fallback=True) + if not domain: + siteurlsplit = urlsplit(settings.SITE_URL) + return siteurlsplit.hostname + return domain + + +def get_stripe_account_key(prov): + if prov.settings.connect_user_id: + return prov.settings.connect_user_id + else: + return prov.settings.publishable_key + + +@app.task(base=EventTask, max_retries=5, default_retry_delay=1) +def stripe_verify_domain(event, domain): + from pretix.plugins.stripe.payment import StripeCC + prov = StripeCC(event) + account = get_stripe_account_key(prov) + + if RegisteredApplePayDomain.objects.filter(account=account, domain=domain).exists(): + return + + try: + resp = stripe.ApplePayDomain.create( + domain_name=domain, + **prov.api_kwargs + ) + except stripe.error.StripeError: + logger.exception('Could not verify domain with Stripe') + else: + if resp.livemode: + RegisteredApplePayDomain.objects.create( + domain=domain, + account=account + ) diff --git a/stripe/templates/pretixplugins/stripe/action_double.html b/stripe/templates/pretixplugins/stripe/action_double.html new file mode 100644 index 0000000..e0ceaf8 --- /dev/null +++ b/stripe/templates/pretixplugins/stripe/action_double.html @@ -0,0 +1,9 @@ +{% load i18n %} + +

+ {% url "control:event.order" organizer=event.organizer.slug event=event.slug code=data.order as ourl %} + {% blocktrans trimmed with charge=data.charge stripe_href="href='https://dashboard.stripe.com/payments/"|add:data.charge|add:"' target='_blank'"|safe order=""|add:data.order|add:""|safe %} + The Stripe transaction {{ charge }} has succeeded, but the order {{ order }} has + already been paid by other means. Please double-check and refund the money via Stripe's interface. + {% endblocktrans %} +

diff --git a/stripe/templates/pretixplugins/stripe/action_overpaid.html b/stripe/templates/pretixplugins/stripe/action_overpaid.html new file mode 100644 index 0000000..8d7d68b --- /dev/null +++ b/stripe/templates/pretixplugins/stripe/action_overpaid.html @@ -0,0 +1,10 @@ +{% load i18n %} + +

+ {% url "control:event.order" organizer=event.organizer.slug event=event.slug code=data.order as ourl %} + {% blocktrans trimmed with charge=data.charge stripe_href="href='https://dashboard.stripe.com/payments/"|add:data.charge|add:"' target='_blank'"|safe order=""|add:data.order|add:""|safe %} + The Stripe transaction {{ charge }} has succeeded, but the order {{ order }} is + expired and the product was sold out in the meantime. Therefore, the payment could not be accepted. Please + contact the user and refund the money via Stripe's interface. + {% endblocktrans %} +

diff --git a/stripe/templates/pretixplugins/stripe/action_refund.html b/stripe/templates/pretixplugins/stripe/action_refund.html new file mode 100644 index 0000000..30e0a44 --- /dev/null +++ b/stripe/templates/pretixplugins/stripe/action_refund.html @@ -0,0 +1,9 @@ +{% load i18n %} + +

+ {% url "control:event.order" organizer=event.organizer.slug event=event.slug code=data.order as ourl %} + {% blocktrans trimmed with charge=data.charge stripe_href="href='https://dashboard.stripe.com/payments/"|add:data.charge|add:"' target='_blank'"|safe order=""|add:data.order|add:""|safe %} + Stripe reported that the transaction {{ charge }} has been refunded. + Do you want to refund mark the matching order ({{ order }}) as refunded? + {% endblocktrans %} +

diff --git a/stripe/templates/pretixplugins/stripe/apple-developer-merchantid-domain-association b/stripe/templates/pretixplugins/stripe/apple-developer-merchantid-domain-association new file mode 100644 index 0000000..2ff95c9 --- /dev/null +++ b/stripe/templates/pretixplugins/stripe/apple-developer-merchantid-domain-association @@ -0,0 +1 @@ +7B227073704964223A2239373943394538343346343131343044463144313834343232393232313734313034353044314339464446394437384337313531303944334643463542433731222C2276657273696F6E223A312C22637265617465644F6E223A313536363233343735303036312C227369676E6174757265223A22333038303036303932613836343838366637306430313037303261303830333038303032303130313331306633303064303630393630383634383031363530333034303230313035303033303830303630393261383634383836663730643031303730313030303061303830333038323033653333303832303338386130303330323031303230323038346333303431343935313964353433363330306130363038326138363438636533643034303330323330376133313265333032633036303335353034303330633235343137303730366336353230343137303730366336393633363137343639366636653230343936653734363536373732363137343639366636653230343334313230326432303437333333313236333032343036303335353034306230633164343137303730366336353230343336353732373436393636363936333631373436393666366532303431373537343638366637323639373437393331313333303131303630333535303430613063306134313730373036633635323034393665363332653331306233303039303630333535303430363133303235353533333031653137306433313339333033353331333833303331333333323335333735613137306433323334333033353331333633303331333333323335333735613330356633313235333032333036303335353034303330633163363536333633326437333664373032643632373236663662363537323264373336393637366535663535343333343264353035323466343433313134333031323036303335353034306230633062363934663533323035333739373337343635366437333331313333303131303630333535303430613063306134313730373036633635323034393665363332653331306233303039303630333535303430363133303235353533333035393330313330363037326138363438636533643032303130363038326138363438636533643033303130373033343230303034633231353737656465626436633762323231386636386464373039306131323138646337623062643666326332383364383436303935643934616634613534313162383334323065643831316633343037653833333331663163353463336637656233323230643662616435643465666634393238393839336537633066313361333832303231313330383230323064333030633036303335353164313330313031666630343032333030303330316630363033353531643233303431383330313638303134323366323439633434663933653465663237653663346636323836633366613262626664326534623330343530363038326230363031303530353037303130313034333933303337333033353036303832623036303130353035303733303031383632393638373437343730336132663266366636333733373032653631373037303663363532653633366636643266366636333733373033303334326436313730373036633635363136393633363133333330333233303832303131643036303335353164323030343832303131343330383230313130333038323031306330363039326138363438383666373633363430353031333038316665333038316333303630383262303630313035303530373032303233303831623630633831623335323635366336393631366536333635323036663665323037343638363937333230363336353732373436393636363936333631373436353230363237393230363136653739323037303631373237343739323036313733373337353664363537333230363136333633363537303734363136653633363532303666363632303734363836353230373436383635366532303631373037303663363936333631363236633635323037333734363136653634363137323634323037343635373236643733323036313665363432303633366636653634363937343639366636653733323036663636323037353733363532633230363336353732373436393636363936333631373436353230373036663663363936333739323036313665363432303633363537323734363936363639363336313734363936663665323037303732363136333734363936333635323037333734363137343635366436353665373437333265333033363036303832623036303130353035303730323031313632613638373437343730336132663266373737373737326536313730373036633635326536333666366432663633363537323734363936363639363336313734363536313735373436383666373236393734373932663330333430363033353531643166303432643330326233303239613032376130323538363233363837343734373033613266326636333732366332653631373037303663363532653633366636643266363137303730366336353631363936333631333332653633373236633330316430363033353531643065303431363034313439343537646236666435373438313836383938393736326637653537383530376537396235383234333030653036303335353164306630313031666630343034303330323037383033303066303630393261383634383836663736333634303631643034303230353030333030613036303832613836343863653364303430333032303334393030333034363032323130306265303935373166653731653165373335623535653561666163623463373266656234343566333031383532323263373235313030326236316562643666353530323231303064313862333530613564643664643665623137343630333562313165623263653837636661336536616636636264383338303839306463383263646461613633333038323032656533303832303237356130303330323031303230323038343936643266626633613938646139373330306130363038326138363438636533643034303330323330363733313162333031393036303335353034303330633132343137303730366336353230353236663666373432303433343132303264323034373333333132363330323430363033353530343062306331643431373037303663363532303433363537323734363936363639363336313734363936663665323034313735373436383666373236393734373933313133333031313036303335353034306130633061343137303730366336353230343936653633326533313062333030393036303335353034303631333032353535333330316531373064333133343330333533303336333233333334333633333330356131373064333233393330333533303336333233333334333633333330356133303761333132653330326330363033353530343033306332353431373037303663363532303431373037303663363936333631373436393666366532303439366537343635363737323631373436393666366532303433343132303264323034373333333132363330323430363033353530343062306331643431373037303663363532303433363537323734363936363639363336313734363936663665323034313735373436383666373236393734373933313133333031313036303335353034306130633061343137303730366336353230343936653633326533313062333030393036303335353034303631333032353535333330353933303133303630373261383634386365336430323031303630383261383634386365336430333031303730333432303030346630313731313834313964373634383564353161356532353831303737366538383061326566646537626165346465303864666334623933653133333536643536363562333561653232643039373736306432323465376262613038666437363137636538386362373662623636373062656338653832393834666635343435613338316637333038316634333034363036303832623036303130353035303730313031303433613330333833303336303630383262303630313035303530373330303138363261363837343734373033613266326636663633373337303265363137303730366336353265363336663664326636663633373337303330333432643631373037303663363537323666366637343633363136373333333031643036303335353164306530343136303431343233663234396334346639336534656632376536633466363238366333666132626266643265346233303066303630333535316431333031303166663034303533303033303130316666333031663036303335353164323330343138333031363830313462626230646561313538333338383961613438613939646562656264656261666461636232346162333033373036303335353164316630343330333032653330326361303261613032383836323636383734373437303361326632663633373236633265363137303730366336353265363336663664326636313730373036633635373236663666373436333631363733333265363337323663333030653036303335353164306630313031666630343034303330323031303633303130303630613261383634383836663736333634303630323065303430323035303033303061303630383261383634386365336430343033303230333637303033303634303233303361636637323833353131363939623138366662333563333536636136326266663431376564643930663735346461323865626566313963383135653432623738396638393866373962353939663938643534313064386639646539633266653032333033323264643534343231623061333035373736633564663333383362393036376664313737633263323136643936346663363732363938323132366635346638376137643162393963623962303938393231363130363939306630393932316430303030333138323031386233303832303138373032303130313330383138363330376133313265333032633036303335353034303330633235343137303730366336353230343137303730366336393633363137343639366636653230343936653734363536373732363137343639366636653230343334313230326432303437333333313236333032343036303335353034306230633164343137303730366336353230343336353732373436393636363936333631373436393666366532303431373537343638366637323639373437393331313333303131303630333535303430613063306134313730373036633635323034393665363332653331306233303039303630333535303430363133303235353533303230383463333034313439353139643534333633303064303630393630383634383031363530333034303230313035303061303831393533303138303630393261383634383836663730643031303930333331306230363039326138363438383666373064303130373031333031633036303932613836343838366637306430313039303533313066313730643331333933303338333133393331333733313332333333303561333032613036303932613836343838366637306430313039333433313164333031623330306430363039363038363438303136353033303430323031303530306131306130363038326138363438636533643034303330323330326630363039326138363438383666373064303130393034333132323034323062303731303365313430613462386231376262613230316130336163643036396234653431366232613263383066383661383338313435633239373566633131333030613036303832613836343863653364303430333032303434363330343430323230343639306264636637626461663833636466343934396534633035313039656463663334373665303564373261313264376335666538633033303033343464663032323032363764353863393365626233353031333836363062353730373938613064643731313734316262353864626436613138363633353038353431656565393035303030303030303030303030227D \ No newline at end of file diff --git a/stripe/templates/pretixplugins/stripe/checkout_payment_confirm.html b/stripe/templates/pretixplugins/stripe/checkout_payment_confirm.html new file mode 100644 index 0000000..97f0e12 --- /dev/null +++ b/stripe/templates/pretixplugins/stripe/checkout_payment_confirm.html @@ -0,0 +1,29 @@ +{% load i18n %} + +{% if provider.method == "cc" %} +

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

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

{% 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 %}

+
+
{% trans "Payment method" %}
+
{{ provider.public_name }}
+ {% if provider.method == "giropay" %} +
{% trans "Account holder" %}
+
{{ request.session.payment_stripe_giropay_account }}
+ {% elif provider.method == "bancontact" %} +
{% trans "Account holder" %}
+
{{ request.session.payment_stripe_bancontact_account }}
+ {% endif %} +
+{% endif %} diff --git a/stripe/templates/pretixplugins/stripe/checkout_payment_form_cc.html b/stripe/templates/pretixplugins/stripe/checkout_payment_form_cc.html new file mode 100644 index 0000000..c9b2799 --- /dev/null +++ b/stripe/templates/pretixplugins/stripe/checkout_payment_form_cc.html @@ -0,0 +1,75 @@ +{% load i18n %} + +
+ {% if is_moto %} +

+ MOTO +

+
+ {% endif %} + +
+ +
+ + + {% 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. + {% endblocktrans %}

+
+
{% trans "Card type" %}
+
{{ request.session.payment_stripe_brand }}
+
{% trans "Card number" %}
+
+ **** **** **** + {{ request.session.payment_stripe_last4 }} + +
+
+
+ {% 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 %} + + + + + +

+
diff --git a/stripe/templates/pretixplugins/stripe/checkout_payment_form_simple.html b/stripe/templates/pretixplugins/stripe/checkout_payment_form_simple.html new file mode 100644 index 0000000..b1a3402 --- /dev/null +++ b/stripe/templates/pretixplugins/stripe/checkout_payment_form_simple.html @@ -0,0 +1,7 @@ +{% load i18n %} +{% load bootstrap3 %} +{% bootstrap_form form layout='horizontal' %} +

{% 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/stripe/templates/pretixplugins/stripe/checkout_payment_form_simple_noform.html b/stripe/templates/pretixplugins/stripe/checkout_payment_form_simple_noform.html new file mode 100644 index 0000000..e54c769 --- /dev/null +++ b/stripe/templates/pretixplugins/stripe/checkout_payment_form_simple_noform.html @@ -0,0 +1,5 @@ +{% load i18n %} +

{% 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/stripe/templates/pretixplugins/stripe/control.html b/stripe/templates/pretixplugins/stripe/control.html new file mode 100644 index 0000000..0711c73 --- /dev/null +++ b/stripe/templates/pretixplugins/stripe/control.html @@ -0,0 +1,69 @@ +{% load i18n %} + +{% if payment_info %} +
+ {% if "id" in payment_info %} +
{% trans "Charge ID" %}
+
{{ payment_info.id }}
+ {% endif %} + {% if "source" in payment_info %} + {% if payment_info.source.card %} +
{% trans "Card type" %}
+
{{ payment_info.source.card.brand }}
+
{% trans "Card number" %}
+
**** **** **** {{ payment_info.source.card.last4 }}
+ {% if payment_info.source.owner.name %} +
{% trans "Payer name" %}
+
{{ payment_info.source.owner.name }}
+ {% endif %} + {% endif %} + {% if payment_info.source.type == "giropay" %} +
{% trans "Bank" %}
+
{{ payment_info.source.giropay.bank_name }} ({{ payment_info.source.giropay.bic }})
+
{% trans "Payer name" %}
+
{{ payment_info.source.owner.verified_name|default:payment_info.source.owner.name }}
+ {% endif %} + {% if payment_info.source.type == "bancontact" %} +
{% trans "Bank" %}
+
{{ payment_info.source.bancontact.bank_name }} ({{ payment_info.source.bancontact.bic }})
+
{% trans "Payer name" %}
+
{{ payment_info.source.owner.verified_name|default:payment_info.source.owner.name }}
+ {% endif %} + {% if payment_info.source.type == "ideal" %} +
{% trans "Bank" %}
+
{{ payment_info.source.ideal.bank }} ({{ payment_info.source.ideal.bic }})
+
{% trans "Payer name" %}
+
{{ payment_info.source.owner.verified_name|default:payment_info.source.owner.name }}
+ {% endif %} + {% endif %} + {% if payment_info.charges.data.0 %} + {% if payment_info.charges.data.0.payment_method_details.card %} +
{% trans "Card type" %}
+
{{ payment_info.charges.data.0.payment_method_details.card.brand }}
+
{% trans "Card number" %}
+
+ **** **** **** {{ payment_info.charges.data.0.payment_method_details.card.last4 }} + {% if payment_info.charges.data.0.payment_method_details.card.moto %} + {% trans "MOTO" %} + {% endif %} +
+ {% endif %} + {% endif %} + {% if "amount" in payment_info %} +
{% trans "Total value" %}
+
{{ payment_info.amount|floatformat:2 }}
+ {% endif %} + {% if "currency" in payment_info %} +
{% trans "Currency" %}
+
{{ payment_info.currency|upper }}
+ {% endif %} + {% if "status" in payment_info %} +
{% trans "Status" %}
+
{{ payment_info.status }}
+ {% endif %} + {% if "message" in payment_info %} +
{% trans "Error message" %}
+
{{ payment_info.message }}
+ {% endif %} +
+{% endif %} diff --git a/stripe/templates/pretixplugins/stripe/organizer_stripe.html b/stripe/templates/pretixplugins/stripe/organizer_stripe.html new file mode 100644 index 0000000..e42a60f --- /dev/null +++ b/stripe/templates/pretixplugins/stripe/organizer_stripe.html @@ -0,0 +1,24 @@ +{% extends "pretixcontrol/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% load hierarkey_form %} +{% load formset_tags %} +{% block title %}{% trans "Stripe Connect" %}{% endblock %} +{% block content %} +

+ {% trans "Stripe Connect" %} +

+ +
+ {% csrf_token %} + {% url "control:global.settings" as g_url %} + {% propagated request.organizer g_url "payment_stripe_connect_app_fee_percent" "payment_stripe_connect_app_fee_min" "payment_stripe_connect_app_fee_max" %} + {% bootstrap_form form layout="control" %} + {% endpropagated %} +
+ +
+
+{% endblock %} diff --git a/stripe/templates/pretixplugins/stripe/pending.html b/stripe/templates/pretixplugins/stripe/pending.html new file mode 100644 index 0000000..67d7a94 --- /dev/null +++ b/stripe/templates/pretixplugins/stripe/pending.html @@ -0,0 +1,39 @@ +{% 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" %} + +
+
+

+{% elif payment.state == "created" and payment.provider == "stripe_wechatpay" %} +

{% blocktrans trimmed %} + Please scan the barcode below to complete your WeChat payment. + Once you have completed your payment, you can refresh this page. + {% endblocktrans %}

+
+ +
+{% else %} +

{% blocktrans trimmed %} + The payment transaction could not be completed for the following reason: + {% endblocktrans %} +
+ {% if payment_info and payment_info.error %} + {{ payment_info.message }} + {% else %} + {% trans "Unknown reason" %} + {% endif %} +

+{% endif %} \ No newline at end of file diff --git a/stripe/templates/pretixplugins/stripe/presale_head.html b/stripe/templates/pretixplugins/stripe/presale_head.html new file mode 100644 index 0000000..6aa3578 --- /dev/null +++ b/stripe/templates/pretixplugins/stripe/presale_head.html @@ -0,0 +1,19 @@ +{% load static %} +{% load compress %} +{% load i18n %} + +{% compress js %} + +{% endcompress %} +{% compress css %} + +{% endcompress %} +{% if testmode %} + +{% else %} + +{% endif %} +{% if settings.connect_user_id %} + +{% endif %} + diff --git a/stripe/templates/pretixplugins/stripe/redirect.html b/stripe/templates/pretixplugins/stripe/redirect.html new file mode 100644 index 0000000..2b7ff29 --- /dev/null +++ b/stripe/templates/pretixplugins/stripe/redirect.html @@ -0,0 +1,32 @@ +{% load compress %} +{% load i18n %} +{% load static %} + + + + {{ settings.PRETIX_INSTANCE_NAME }} + {% compress css %} + + {% endcompress %} + {% compress js %} + + {% endcompress %} + + +
+

{% trans "The payment process has started in a new window." %}

+ +

+ {% trans "The window to enter your payment data was not opened or was closed?" %} +

+

+ + {% trans "Click here in order to open the window." %} + +

+ +
+ + diff --git a/stripe/templates/pretixplugins/stripe/sca.html b/stripe/templates/pretixplugins/stripe/sca.html new file mode 100644 index 0000000..02a51ae --- /dev/null +++ b/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 %} +

+
+
+ +
+
+
+ + +
+
+{% endblock %} diff --git a/stripe/templates/pretixplugins/stripe/sca_return.html b/stripe/templates/pretixplugins/stripe/sca_return.html new file mode 100644 index 0000000..3579b6d --- /dev/null +++ b/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/stripe/urls.py b/stripe/urls.py new file mode 100644 index 0000000..82af46c --- /dev/null +++ b/stripe/urls.py @@ -0,0 +1,38 @@ +from django.conf.urls import include, url + +from pretix.multidomain import event_url + +from .views import ( + OrganizerSettingsFormView, ReturnView, ScaReturnView, ScaView, + applepay_association, oauth_disconnect, oauth_return, redirect_view, + webhook, +) + +event_patterns = [ + url(r'^stripe/', include([ + 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'), + ])), + url(r'^.well-known/apple-developer-merchantid-domain-association$', + applepay_association, name='applepay.association'), +] + +organizer_patterns = [ + url(r'^.well-known/apple-developer-merchantid-domain-association$', + applepay_association, name='applepay.association'), +] + +urlpatterns = [ + url(r'^control/event/(?P[^/]+)/(?P[^/]+)/stripe/disconnect/', + oauth_disconnect, name='oauth.disconnect'), + url(r'^control/organizer/(?P[^/]+)/stripeconnect/', + OrganizerSettingsFormView.as_view(), name='settings.connect'), + url(r'^_stripe/webhook/$', webhook, name='webhook'), + url(r'^_stripe/oauth_return/$', oauth_return, name='oauth.return'), + url(r'^.well-known/apple-developer-merchantid-domain-association$', + applepay_association, name='applepay.association'), +] diff --git a/stripe/utils.py b/stripe/utils.py new file mode 100644 index 0000000..e69de29 diff --git a/stripe/views.py b/stripe/views.py new file mode 100644 index 0000000..61d9162 --- /dev/null +++ b/stripe/views.py @@ -0,0 +1,602 @@ +import hashlib +import json +import logging + +import requests +import stripe +from django.contrib import messages +from django.core import signing +from django.db import transaction +from django.http import Http404, HttpResponse, HttpResponseBadRequest +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse +from django.utils.decorators import method_decorator +from django.utils.functional import cached_property +from django.utils.translation import gettext_lazy as _ +from django.views import View +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 django.views.generic import FormView +from django_scopes import scopes_disabled + +from pretix.base.models import Event, Order, OrderPayment, Organizer, Quota +from pretix.base.payment import PaymentException +from pretix.base.services.locking import LockTimeoutException +from pretix.base.settings import GlobalSettingsObject +from pretix.control.permissions import ( + AdministratorPermissionRequiredMixin, event_permission_required, +) +from pretix.control.views.event import DecoupleMixin +from pretix.control.views.organizer import OrganizerDetailViewMixin +from pretix.multidomain.urlreverse import eventreverse +from pretix.plugins.stripe.forms import OrganizerStripeSettingsForm +from pretix.plugins.stripe.models import ReferencedStripeObject +from pretix.plugins.stripe.payment import StripeCC, StripeSettingsHolder +from pretix.plugins.stripe.tasks import ( + get_domain_for_event, stripe_verify_domain, +) + +logger = logging.getLogger('pretix.plugins.stripe') + + +@xframe_options_exempt +def redirect_view(request, *args, **kwargs): + signer = signing.Signer(salt='safe-redirect') + try: + url = signer.unsign(request.GET.get('url', '')) + except signing.BadSignature: + return HttpResponseBadRequest('Invalid parameter') + + r = render(request, 'pretixplugins/stripe/redirect.html', { + 'url': url, + }) + r._csp_ignore = True + return r + + +@scopes_disabled() +def oauth_return(request, *args, **kwargs): + if 'payment_stripe_oauth_event' not in request.session: + messages.error(request, _('An error occurred 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 occurred 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() + testdata = {} + + 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 occurred during connecting with Stripe, please try again.')) + else: + if 'error' not in data and data['livemode']: + try: + testresp = requests.post('https://connect.stripe.com/oauth/token', data={ + 'grant_type': 'refresh_token', + 'client_secret': gs.settings.payment_stripe_connect_test_secret_key, + 'refresh_token': data['refresh_token'] + }) + testdata = testresp.json() + except: + logger.exception('Failed to obtain OAuth token') + messages.error(request, _('An error occurred 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' + })) + + if 'error' in data: + messages.error(request, _('Stripe returned an error: {}').format(data['error_description'])) + elif data['livemode'] and 'error' in testdata: + messages.error(request, _('Stripe returned an error: {}').format(testdata['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_merchant_country = account.get('country') + if account.get('business_name') or account.get('display_name') or account.get('email'): + event.settings.payment_stripe_connect_user_name = ( + account.get('business_name') or account.get('display_name') or account.get('email') + ) + + if data['livemode']: + event.settings.payment_stripe_publishable_test_key = testdata['stripe_publishable_key'] + else: + event.settings.payment_stripe_publishable_test_key = event.settings.payment_stripe_publishable_key + + if request.session.get('payment_stripe_oauth_enable', False): + event.settings.payment_stripe__enabled = True + del request.session['payment_stripe_oauth_enable'] + + stripe_verify_domain.apply_async(args=(event.pk, get_domain_for_event(event))) + + return redirect(reverse('control:event.settings.payment.provider', kwargs={ + 'organizer': event.organizer.slug, + 'event': event.slug, + 'provider': 'stripe_settings' + })) + + +@csrf_exempt +@require_POST +@scopes_disabled() +def webhook(request, *args, **kwargs): + event_json = json.loads(request.body.decode('utf-8')) + + # We do not check for the event type as we are not interested in the event it self, + # we just use it as a trigger to look the charge up to be absolutely sure. + # Another reason for this is that stripe events are not authenticated, so they could + # come from anywhere. + + if event_json['data']['object']['object'] == "charge": + func = charge_webhook + objid = event_json['data']['object']['id'] + elif event_json['data']['object']['object'] == "dispute": + func = charge_webhook + objid = event_json['data']['object']['charge'] + 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) + + try: + rso = ReferencedStripeObject.objects.select_related('order', 'order__event').get(reference=objid) + return func(rso.order.event, event_json, objid, rso) + except ReferencedStripeObject.DoesNotExist: + if event_json['data']['object']['object'] == "charge" and 'payment_intent' in event_json['data']['object']: + # If we receive a charge webhook *before* the payment intent webhook, we don't know the charge ID yet + # and can't match it -- but we know the payment intent ID! + try: + rso = ReferencedStripeObject.objects.select_related('order', 'order__event').get( + reference=event_json['data']['object']['payment_intent'] + ) + return func(rso.order.event, event_json, objid, rso) + except ReferencedStripeObject.DoesNotExist: + return HttpResponse("Unable to detect event", status=200) + elif hasattr(request, 'event') and func != paymentintent_webhook: + # This is a legacy integration from back when didn't have ReferencedStripeObject. This can't happen for + # payment intents or charges connected with payment intents since they didn't exist back then. Our best + # hope is to go for request.event and see if we can find the order ID. + return func(request.event, event_json, objid, None) + else: + # Okay, this is probably not an event that concerns us, maybe other applications talk to the same stripe + # account + return HttpResponse("Unable to detect event", status=200) + + +SOURCE_TYPES = { + 'sofort': 'stripe_sofort', + 'three_d_secure': 'stripe', + 'card': 'stripe', + 'giropay': 'stripe_giropay', + 'ideal': 'stripe_ideal', + 'alipay': 'stripe_alipay', + 'bancontact': 'stripe_bancontact', +} + + +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: + logger.exception('Stripe error on webhook. Event data: %s' % str(event_json)) + return HttpResponse('Charge not found', status=500) + + metadata = charge['metadata'] + if 'event' not in metadata: + return HttpResponse('Event not given in charge metadata', status=200) + + if int(metadata['event']) != event.pk: + return HttpResponse('Not interested in this event', status=200) + + if rso and rso.payment: + order = rso.payment.order + payment = rso.payment + elif rso: + order = rso.order + payment = None + else: + try: + order = event.orders.get(id=metadata['order']) + except Order.DoesNotExist: + return HttpResponse('Order not found', status=200) + payment = None + + with transaction.atomic(): + if not payment: + payment = order.payments.filter( + info__icontains=charge['id'], + provider__startswith='stripe', + amount=prov._amount_to_decimal(charge['amount']), + ).select_for_update().last() + if not payment: + payment = order.payments.create( + state=OrderPayment.PAYMENT_STATE_CREATED, + provider=SOURCE_TYPES.get(charge['source'].get('type', charge['source'].get('object', 'card')), 'stripe'), + amount=prov._amount_to_decimal(charge['amount']), + info=str(charge), + ) + + if payment.provider != prov.identifier: + prov = payment.payment_provider + prov._init_api() + + order.log_action('pretix.plugins.stripe.event', data=event_json) + + is_refund = charge['refunds']['total_count'] or charge['dispute'] + if is_refund: + known_refunds = [r.info_data.get('id') for r in payment.refunds.all()] + migrated_refund_amounts = [r.amount for r in payment.refunds.all() if not r.info_data.get('id')] + for r in charge['refunds']['data']: + a = prov._amount_to_decimal(r['amount']) + if r['status'] in ('failed', 'canceled'): + continue + + if a in migrated_refund_amounts: + migrated_refund_amounts.remove(a) + continue + + if r['id'] not in known_refunds: + payment.create_external_refund( + amount=a, + info=str(r) + ) + if charge['dispute']: + if charge['dispute']['status'] != 'won' and charge['dispute']['id'] not in known_refunds: + a = prov._amount_to_decimal(charge['dispute']['amount']) + if a in migrated_refund_amounts: + migrated_refund_amounts.remove(a) + else: + payment.create_external_refund( + amount=a, + info=str(charge['dispute']) + ) + elif charge['status'] == 'succeeded' and payment.state in (OrderPayment.PAYMENT_STATE_PENDING, + OrderPayment.PAYMENT_STATE_CREATED, + OrderPayment.PAYMENT_STATE_CANCELED, + OrderPayment.PAYMENT_STATE_FAILED): + try: + payment.confirm() + except LockTimeoutException: + return HttpResponse("Lock timeout, please try again.", status=503) + except Quota.QuotaExceededException: + pass + elif charge['status'] == 'failed' and payment.state in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED): + payment.fail(info=str(charge)) + + return HttpResponse(status=200) + + +def source_webhook(event, event_json, source_id, rso): + prov = StripeCC(event) + prov._init_api() + try: + 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) + + metadata = src['metadata'] + if 'event' not in metadata: + return HttpResponse('Event not given in charge metadata', status=200) + + if int(metadata['event']) != event.pk: + return HttpResponse('Not interested in this event', status=200) + + with transaction.atomic(): + if rso and rso.payment: + order = rso.payment.order + payment = rso.payment + elif rso: + order = rso.order + payment = None + else: + try: + order = event.orders.get(id=metadata['order']) + except Order.DoesNotExist: + return HttpResponse('Order not found', status=200) + payment = None + + if not payment: + payment = order.payments.filter( + info__icontains=src['id'], + provider__startswith='stripe', + amount=prov._amount_to_decimal(src['amount']) if src['amount'] is not None else order.total, + ).last() + if not payment: + payment = order.payments.create( + state=OrderPayment.PAYMENT_STATE_CREATED, + provider=SOURCE_TYPES.get(src['type'], 'stripe'), + amount=prov._amount_to_decimal(src['amount']) if src['amount'] is not None else order.total, + info=str(src), + ) + + if payment.provider != prov.identifier: + prov = payment.payment_provider + prov._init_api() + + order.log_action('pretix.plugins.stripe.event', data=event_json) + go = (event_json['type'] == 'source.chargeable' and + payment.state in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED) and + src.status == 'chargeable') + if go: + try: + prov._charge_source(None, source_id, payment) + except PaymentException: + logger.exception('Webhook error') + + elif src.status == 'failed': + payment.fail(info=str(src)) + elif src.status == 'canceled' and payment.state in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED): + payment.info = str(src) + payment.state = OrderPayment.PAYMENT_STATE_CANCELED + payment.save() + + 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): + del request.event.settings.payment_stripe_publishable_key + del request.event.settings.payment_stripe_publishable_test_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' + })) + + +@xframe_options_exempt +def applepay_association(request, *args, **kwargs): + r = render(request, 'pretixplugins/stripe/apple-developer-merchantid-domain-association') + r._csp_ignore = True + return r + + +class StripeOrderView: + def dispatch(self, request, *args, **kwargs): + try: + self.order = request.event.orders.get(code=kwargs['order']) + if hashlib.sha1(self.order.secret.lower().encode()).hexdigest() != kwargs['hash'].lower(): + raise Http404('') + except Order.DoesNotExist: + # Do a hash comparison as well to harden timing attacks + if 'abcdefghijklmnopq'.lower() == hashlib.sha1('abcdefghijklmnopq'.encode()).hexdigest(): + raise Http404('') + else: + raise Http404('') + return super().dispatch(request, *args, **kwargs) + + @cached_property + def payment(self): + return get_object_or_404(self.order.payments, + pk=self.kwargs['payment'], + provider__startswith='stripe') + + @cached_property + 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 and self.payment.provider != 'stripe_ideal': + 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() + 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. Please check the link ' + '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() + if self.payment.state == OrderPayment.PAYMENT_STATE_CONFIRMED: + if 'payment_stripe_token' in request.session: + del request.session['payment_stripe_token'] + return self._redirect_to_order() + + if src.status == 'chargeable': + try: + prov._charge_source(request, src.id, self.payment) + except PaymentException as e: + messages.error(request, str(e)) + return self._redirect_to_order() + finally: + if 'payment_stripe_token' in request.session: + del request.session['payment_stripe_token'] + elif src.status == 'consumed': + # Webhook was faster, wow! ;) + if 'payment_stripe_token' in request.session: + del request.session['payment_stripe_token'] + return self._redirect_to_order() + elif src.status == 'pending': + self.payment.state = OrderPayment.PAYMENT_STATE_PENDING + self.payment.info = str(src) + self.payment.save() + else: # failed or canceled + self.payment.fail(info=str(src)) + messages.error(self.request, _('We had trouble authorizing your card payment. Please try again and ' + 'get in touch with us if this problem persists.')) + return self._redirect_to_order() + + +@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}) + + +class OrganizerSettingsFormView(DecoupleMixin, OrganizerDetailViewMixin, AdministratorPermissionRequiredMixin, FormView): + model = Organizer + permission = 'can_change_organizer_settings' + form_class = OrganizerStripeSettingsForm + template_name = 'pretixplugins/stripe/organizer_stripe.html' + + def get_success_url(self): + return reverse('plugins:stripe:settings.connect', kwargs={ + 'organizer': self.request.organizer.slug, + }) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['obj'] = self.request.organizer + return kwargs + + @transaction.atomic + def post(self, request, *args, **kwargs): + form = self.get_form() + if form.is_valid(): + form.save() + if form.has_changed(): + self.request.organizer.log_action( + 'pretix.organizer.settings', user=self.request.user, data={ + k: form.cleaned_data.get(k) for k in form.changed_data + } + ) + messages.success(self.request, _('Your changes have been saved.')) + return redirect(self.get_success_url()) + else: + messages.error(self.request, _('We could not save your changes. See below for details.')) + return self.get(request)