├── pytest.ini ├── eventyay_stripe ├── tests │ ├── __init__.py │ ├── test_settings.py │ ├── test_checkout.py │ ├── test_provider.py │ └── test_webhook.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── stripe_connect_fill_countries.py ├── migrations │ ├── __init__.py │ └── 0001_initial.py ├── __init__.py ├── templates │ └── plugins │ │ └── stripe │ │ ├── checkout_payment_form_simple_noform.html │ │ ├── checkout_payment_form_simple.html │ │ ├── oauth_disconnect.html │ │ ├── presale_head.html │ │ ├── organizer_stripe.html │ │ ├── sca_return.html │ │ ├── simple_messaging_noform.html │ │ ├── redirect.html │ │ ├── checkout_payment_confirm.html │ │ ├── sca.html │ │ ├── pending.html │ │ ├── checkout_payment_form_cc.html │ │ ├── control.html │ │ └── sepadirectdebit.html ├── models.py ├── apps.py ├── urls.py ├── forms.py ├── static │ └── plugins │ │ └── stripe │ │ ├── eventyay-stripe.css │ │ ├── stripe_logo.svg │ │ └── eventyay-stripe.js ├── tasks.py ├── validation_models.py ├── signals.py └── views.py ├── setup.py ├── docs ├── images │ ├── oauth.png │ ├── add-uri.png │ ├── apikeys.png │ ├── dest-url.png │ ├── client-id.png │ ├── dest-type.png │ ├── test-mode.png │ ├── add-webhook.png │ ├── select-event.png │ ├── connect-setting.png │ ├── global-settings.png │ ├── webhook-created.png │ ├── setting-dashboard.png │ ├── webhook-signing-key.png │ ├── global-settings-capture.png │ ├── webhook-select-events.png │ └── webhook-create-event-dest.png └── Readme.md ├── MANIFEST.in ├── README.md ├── Makefile ├── setup.cfg ├── .gitignore ├── pyproject.toml ├── LICENSE └── uv.lock /pytest.ini: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /eventyay_stripe/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /eventyay_stripe/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /eventyay_stripe/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /eventyay_stripe/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /docs/images/oauth.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossasia/eventyay-tickets-stripe/master/docs/images/oauth.png -------------------------------------------------------------------------------- /docs/images/add-uri.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossasia/eventyay-tickets-stripe/master/docs/images/add-uri.png -------------------------------------------------------------------------------- /docs/images/apikeys.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossasia/eventyay-tickets-stripe/master/docs/images/apikeys.png -------------------------------------------------------------------------------- /docs/images/dest-url.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossasia/eventyay-tickets-stripe/master/docs/images/dest-url.png -------------------------------------------------------------------------------- /eventyay_stripe/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.0.1" 2 | default_app_config = 'eventyay-stripe.apps.StripePluginApp' 3 | -------------------------------------------------------------------------------- /docs/images/client-id.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossasia/eventyay-tickets-stripe/master/docs/images/client-id.png -------------------------------------------------------------------------------- /docs/images/dest-type.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossasia/eventyay-tickets-stripe/master/docs/images/dest-type.png -------------------------------------------------------------------------------- /docs/images/test-mode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossasia/eventyay-tickets-stripe/master/docs/images/test-mode.png -------------------------------------------------------------------------------- /docs/images/add-webhook.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossasia/eventyay-tickets-stripe/master/docs/images/add-webhook.png -------------------------------------------------------------------------------- /docs/images/select-event.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossasia/eventyay-tickets-stripe/master/docs/images/select-event.png -------------------------------------------------------------------------------- /docs/images/connect-setting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossasia/eventyay-tickets-stripe/master/docs/images/connect-setting.png -------------------------------------------------------------------------------- /docs/images/global-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossasia/eventyay-tickets-stripe/master/docs/images/global-settings.png -------------------------------------------------------------------------------- /docs/images/webhook-created.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossasia/eventyay-tickets-stripe/master/docs/images/webhook-created.png -------------------------------------------------------------------------------- /docs/images/setting-dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossasia/eventyay-tickets-stripe/master/docs/images/setting-dashboard.png -------------------------------------------------------------------------------- /docs/images/webhook-signing-key.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossasia/eventyay-tickets-stripe/master/docs/images/webhook-signing-key.png -------------------------------------------------------------------------------- /docs/images/global-settings-capture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossasia/eventyay-tickets-stripe/master/docs/images/global-settings-capture.png -------------------------------------------------------------------------------- /docs/images/webhook-select-events.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossasia/eventyay-tickets-stripe/master/docs/images/webhook-select-events.png -------------------------------------------------------------------------------- /docs/images/webhook-create-event-dest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossasia/eventyay-tickets-stripe/master/docs/images/webhook-create-event-dest.png -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include eventyay_stripe/static * 2 | recursive-include eventyay_stripe/templates * 3 | recursive-include eventyay_stripe/locale * 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # eventyay tickets stripe 2 | 3 | This project is a plugin for eventyay tickets. 4 | 5 | ## License 6 | 7 | The project is licensed under the Apache2 license. 8 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: localecompile 2 | LNGS:=`find eventyay_stripe/locale/ -mindepth 1 -maxdepth 1 -type d -printf "-l %f "` 3 | 4 | localecompile: 5 | django-admin compilemessages 6 | 7 | localegen: 8 | django-admin makemessages --keep-pot -i build -i dist -i "*egg*" $(LNGS) 9 | 10 | .PHONY: all localecompile localegen 11 | -------------------------------------------------------------------------------- /eventyay_stripe/templates/plugins/stripe/checkout_payment_form_simple_noform.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 |

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

6 | -------------------------------------------------------------------------------- /eventyay_stripe/templates/plugins/stripe/checkout_payment_form_simple.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% load bootstrap3 %} 3 | {% bootstrap_form form layout='horizontal' %} 4 |

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

8 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = N802,W503,E402,C901,E722,W504,E252,N812,N806,E741 3 | max-line-length = 160 4 | max-complexity = 11 5 | 6 | [isort] 7 | combine_as_imports = true 8 | include_trailing_comma = true 9 | known_first_party = pretix 10 | known_third_party = versions,tests 11 | extra_standard_library = typing,enum,mimetypes 12 | multi_line_output = 5 13 | line_length = 79 14 | honor_noqa = true -------------------------------------------------------------------------------- /eventyay_stripe/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class ReferencedStripeObject(models.Model): 5 | reference = models.CharField(max_length=190, db_index=True, unique=True) 6 | order = models.ForeignKey('pretixbase.Order', on_delete=models.CASCADE) 7 | payment = models.ForeignKey('pretixbase.OrderPayment', null=True, blank=True, on_delete=models.CASCADE) 8 | 9 | 10 | class RegisteredApplePayDomain(models.Model): 11 | domain = models.CharField(max_length=190) 12 | account = models.CharField(max_length=190) 13 | -------------------------------------------------------------------------------- /eventyay_stripe/templates/plugins/stripe/oauth_disconnect.html: -------------------------------------------------------------------------------- 1 | {% extends "pretixcontrol/base.html" %} 2 | {% load i18n %} 3 | {% block title %}{% trans "Stripe Connect" %}{% endblock %} 4 | {% block content %} 5 |

6 | {% trans "Stripe Connect" %} 7 |

8 | 9 |
10 | {% csrf_token %} 11 |

12 | {% trans "Do you really want to disconnect your Stripe account?" %} 13 |

14 |
15 | 18 |
19 |
20 | {% endblock %} 21 | -------------------------------------------------------------------------------- /eventyay_stripe/templates/plugins/stripe/presale_head.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | {% load compress %} 3 | {% load i18n %} 4 | 5 | {% compress js %} 6 | 7 | {% endcompress %} 8 | {% compress css %} 9 | 10 | {% endcompress %} 11 | {% if testmode %} 12 | 13 | {% else %} 14 | 15 | {% endif %} 16 | {% if settings.connect_user_id and not settings.secret_key %} 17 | 18 | {% endif %} 19 | 20 | -------------------------------------------------------------------------------- /eventyay_stripe/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from . import __version__ 5 | 6 | try: 7 | from pretix.base.plugins import PluginConfig 8 | except ImportError: 9 | raise RuntimeError("Python package 'stripe' is not installed.") 10 | 11 | 12 | class StripePluginApp(AppConfig): 13 | default = True 14 | name = 'eventyay_stripe' 15 | verbose_name = _("Stripe") 16 | 17 | class PretixPluginMeta: 18 | name = _("Stripe") 19 | author = "eventyay" 20 | version = __version__ 21 | category = 'PAYMENT' 22 | featured = True 23 | visible = True 24 | description = _("This plugin allows you to receive credit card payments " + 25 | "via Stripe.") 26 | 27 | def ready(self): 28 | from . import signals, tasks # NOQA 29 | 30 | 31 | default_app_config = 'eventyay-stripe.apps.StripePluginApp' 32 | -------------------------------------------------------------------------------- /eventyay_stripe/templates/plugins/stripe/organizer_stripe.html: -------------------------------------------------------------------------------- 1 | {% extends "pretixcontrol/base.html" %} 2 | {% load i18n %} 3 | {% load bootstrap3 %} 4 | {% load hierarkey_form %} 5 | {% load formset_tags %} 6 | {% block title %}{% trans "Stripe Connect" %}{% endblock %} 7 | {% block content %} 8 |

9 | {% trans "Stripe Connect" %} 10 |

11 | 12 |
13 | {% csrf_token %} 14 | {% url "control:global.settings" as g_url %} 15 | {% propagated request.organizer g_url "payment_stripe_connect_app_fee_percent" "payment_stripe_connect_app_fee_min" "payment_stripe_connect_app_fee_max" %} 16 | {% bootstrap_form form layout="control" %} 17 | {% endpropagated %} 18 |
19 | 22 |
23 |
24 | {% endblock %} 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | .ropeproject/ 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *,cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | 52 | # Django stuff: 53 | *.log 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | # PyBuilder 59 | target/ 60 | 61 | #Ipython Notebook 62 | .ipynb_checkpoints 63 | -------------------------------------------------------------------------------- /eventyay_stripe/templates/plugins/stripe/sca_return.html: -------------------------------------------------------------------------------- 1 | {% extends "pretixpresale/base.html" %} 2 | {% load i18n %} 3 | {% load static %} 4 | {% load thumb %} 5 | {% load eventurl %} 6 | {% block title %}{% trans "Pay order" %}{% endblock %} 7 | {% block custom_header %} 8 | {{ block.super }} 9 | {% include "plugins/stripe/presale_head.html" with settings=stripe_settings %} 10 | 11 | 14 | 15 | 16 | {% endblock %} 17 | {% block page %} 18 |
19 | 20 |
21 |

22 | {% trans "Confirming your payment…" %} 23 |

24 | {% endblock %} 25 | -------------------------------------------------------------------------------- /eventyay_stripe/templates/plugins/stripe/simple_messaging_noform.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% load bootstrap3 %} 3 | {% if explanation %} 4 |

{{ explanation }}

5 | {% endif %} 6 |
7 |
8 | 9 | 10 |
11 | 12 |

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

18 | 19 | 20 | 21 | {% if country %} 22 | 23 | {% endif %} 24 |
25 | -------------------------------------------------------------------------------- /eventyay_stripe/templates/plugins/stripe/redirect.html: -------------------------------------------------------------------------------- 1 | {% load compress %} 2 | {% load i18n %} 3 | {% load static %} 4 | 5 | 6 | 7 | {{ settings.INSTANCE_NAME }} 8 | {% compress css %} 9 | 10 | {% endcompress %} 11 | {% compress js %} 12 | 13 | {% endcompress %} 14 | 15 | 16 |
17 |

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

18 | 19 |

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

22 |

23 | 24 | {% trans "Click here in order to open the window." %} 25 | 26 |

27 | 30 |
31 | 32 | 33 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "eventyay-stripe" 3 | dynamic = ["version"] 4 | description = "Integrates pretix with venueless.org" 5 | readme = "README.rst" 6 | requires-python = ">=3.9" 7 | license = {file = "LICENSE"} 8 | keywords = ["pretix"] 9 | authors = [ 10 | {name = "pretix team", email = "support@pretix.eu"}, 11 | ] 12 | maintainers = [ 13 | {name = "pretix team", email = "support@pretix.eu"}, 14 | ] 15 | 16 | dependencies = [ 17 | 'stripe==11.3.*', 18 | ] 19 | 20 | [project.entry-points."pretix.plugin"] 21 | eventyay_stripe = "eventyay_stripe:PretixPluginMeta" 22 | 23 | [project.entry-points."distutils.commands"] 24 | build = "pretix_plugin_build.build:CustomBuild" 25 | 26 | [build-system] 27 | requires = [ 28 | "setuptools", 29 | "pretix-plugin-build", 30 | ] 31 | 32 | [project.urls] 33 | homepage = "https://github.com/pretix/pretix-venueless" 34 | 35 | [tool.setuptools] 36 | include-package-data = true 37 | 38 | [tool.setuptools.dynamic] 39 | version = {attr = "eventyay_stripe.__version__"} 40 | 41 | [tool.setuptools.packages.find] 42 | include = ["eventyay*"] 43 | namespaces = false 44 | -------------------------------------------------------------------------------- /eventyay_stripe/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | import django.db.models.deletion 3 | 4 | 5 | class Migration(migrations.Migration): 6 | 7 | initial = True 8 | 9 | dependencies = [ 10 | ('pretixbase', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='RegisteredApplePayDomain', 16 | fields=[ 17 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), 18 | ('domain', models.CharField(max_length=190)), 19 | ('account', models.CharField(max_length=190)), 20 | ], 21 | ), 22 | migrations.CreateModel( 23 | name='ReferencedStripeObject', 24 | fields=[ 25 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), 26 | ('reference', models.CharField(db_index=True, max_length=190, unique=True)), 27 | ('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.order')), 28 | ('payment', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.orderpayment')), 29 | ], 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /eventyay_stripe/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, re_path 2 | 3 | from pretix.multidomain import event_url 4 | 5 | from .views import ( 6 | OrganizerSettingsFormView, ReturnView, ScaReturnView, ScaView, 7 | oauth_disconnect, oauth_return, redirect_view, webhook, 8 | ) 9 | 10 | event_patterns = [ 11 | re_path(r'^stripe/', include([ 12 | event_url(r'^webhook/$', webhook, name='webhook', require_live=False), 13 | re_path(r'^redirect/$', redirect_view, name='redirect'), 14 | re_path(r'^return/(?P[^/]+)/(?P[^/]+)/(?P[0-9]+)/$', ReturnView.as_view(), name='return'), 15 | re_path(r'^sca/(?P[^/]+)/(?P[^/]+)/(?P[0-9]+)/$', ScaView.as_view(), name='sca'), 16 | re_path(r'^sca/(?P[^/]+)/(?P[^/]+)/(?P[0-9]+)/return/$', ScaReturnView.as_view(), 17 | name='sca.return'), 18 | ])), 19 | ] 20 | 21 | urlpatterns = [ 22 | re_path(r'^control/event/(?P[^/]+)/(?P[^/]+)/stripe/disconnect/$', oauth_disconnect, 23 | name='oauth.disconnect'), 24 | re_path(r'^control/organizer/(?P[^/]+)/stripeconnect/$', OrganizerSettingsFormView.as_view(), 25 | name='settings.connect'), 26 | re_path(r'^_stripe/webhook/$', webhook, name='webhook'), 27 | re_path(r'^_stripe/oauth_return/$', oauth_return, name='oauth.return'), 28 | ] 29 | -------------------------------------------------------------------------------- /eventyay_stripe/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from pretix.base.forms import SettingsForm 5 | 6 | 7 | class StripeKeyValidator: 8 | def __init__(self, prefix): 9 | assert len(prefix) > 0 10 | if isinstance(prefix, list): 11 | self._prefixes = prefix 12 | else: 13 | self._prefixes = [prefix] 14 | assert isinstance(prefix, str) 15 | 16 | def __call__(self, value): 17 | if not any(value.startswith(p) for p in self._prefixes): 18 | raise forms.ValidationError( 19 | _('The provided key "%(value)s" does not look valid. It should start with "%(prefix)s".'), 20 | code='invalid-stripe-key', 21 | params={ 22 | 'value': value, 23 | 'prefix': self._prefixes[0], 24 | }, 25 | ) 26 | 27 | 28 | class OrganizerStripeSettingsForm(SettingsForm): 29 | payment_stripe_connect_app_fee_percent = forms.DecimalField( 30 | label=_('Stripe Connect: App fee (percent)'), 31 | required=False, 32 | ) 33 | payment_stripe_connect_app_fee_max = forms.DecimalField( 34 | label=_('Stripe Connect: App fee (max)'), 35 | required=False, 36 | ) 37 | payment_stripe_connect_app_fee_min = forms.DecimalField( 38 | label=_('Stripe Connect: App fee (min)'), 39 | required=False, 40 | ) 41 | -------------------------------------------------------------------------------- /eventyay_stripe/management/commands/stripe_connect_fill_countries.py: -------------------------------------------------------------------------------- 1 | import stripe 2 | from django.core.management.base import BaseCommand 3 | from django_scopes import scopes_disabled 4 | 5 | from pretix.base.models import Event 6 | from pretix.base.settings import GlobalSettingsObject 7 | 8 | 9 | class Command(BaseCommand): 10 | help = "Detect country for Stripe Connect accounts connected with eventyay (required for payment request buttons)" 11 | 12 | @scopes_disabled() 13 | def handle(self, *args, **options): 14 | cache = {} 15 | gs = GlobalSettingsObject() 16 | api_key = gs.settings.payment_stripe_connect_secret_key or gs.settings.payment_stripe_connect_test_secret_key 17 | if not api_key: 18 | self.stderr.write(self.style.ERROR("Stripe Connect is not set up!")) 19 | return 20 | 21 | for e in Event.objects.filter(plugins__icontains="pretix.plugins.stripe"): 22 | uid = e.settings.payment_stripe_connect_user_id 23 | if uid and not e.settings.payment_stripe_merchant_country: 24 | if uid in cache: 25 | e.settings.payment_stripe_merchant_country = cache[uid] 26 | else: 27 | try: 28 | account = stripe.Account.retrieve( 29 | uid, 30 | api_key=api_key 31 | ) 32 | except Exception as e: 33 | print(e) 34 | else: 35 | e.settings.payment_stripe_merchant_country = cache[uid] = account.get('country') 36 | -------------------------------------------------------------------------------- /eventyay_stripe/templates/plugins/stripe/checkout_payment_confirm.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | {% if provider.method == "card" %} 4 |

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

7 |
8 |
{% trans "Card type" %}
9 |
{{ request.session.payment_stripe_card_brand }}
10 |
{% trans "Card number" %}
11 |
**** **** **** {{ request.session.payment_stripe_card_last4 }}
12 |
13 | {% elif provider.method == "sepa_debit" %} 14 |

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

17 |
18 |
{% trans "Banking Institution" %}
19 |
{{ request.session.payment_stripe_sepa_debit_bank }}
20 |
{% trans "Account number" %}
21 |
**** **** **** {{ request.session.payment_stripe_sepa_debit_last4 }}
22 |
23 | {% else %} 24 |

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

28 |
29 |
{% trans "Payment method" %}
30 |
{{ provider.public_name }}
31 | {% if provider.method == "bancontact" %} 32 |
{% trans "Account holder" %}
33 |
{{ request.session.payment_stripe_bancontact_account }}
34 | {% endif %} 35 |
36 | {% endif %} 37 | -------------------------------------------------------------------------------- /eventyay_stripe/static/plugins/stripe/eventyay-stripe.css: -------------------------------------------------------------------------------- 1 | .sep { 2 | } 3 | 4 | .sepText { 5 | width: 75px; 6 | background: #FFFFFF; 7 | margin: -15px 0 0 -38px; 8 | padding: 5px 0; 9 | position: absolute; 10 | top: 50%; 11 | text-align: center; 12 | } 13 | 14 | .hr { 15 | width:2px; 16 | height:64px; 17 | background-color: #DDDDDD; 18 | position:inherit; 19 | top:0px; 20 | left:50%; 21 | z-index:10; 22 | } 23 | #stripe-card { 24 | margin: 15px 0; 25 | } 26 | 27 | .embed-responsive-sca { 28 | padding-bottom: 75%; 29 | min-height: 600px; 30 | } 31 | 32 | @media only screen and (max-width: 999px) { 33 | .hr { 34 | width: 100%; 35 | height: 2px; 36 | left: 0px; 37 | margin: 15px 0 15px 0; 38 | } 39 | .sepText { 40 | left: 50%; 41 | } 42 | #stripe-card-elements > div.hidden { 43 | height: 0; 44 | padding-top: 0; 45 | padding-bottom: 0; 46 | overflow: hidden; 47 | display: block !important; 48 | } 49 | #stripe-card-elements .stripe-or { 50 | height: 16px; 51 | } 52 | #stripe-card-elements .stripe-payment-request-button { 53 | height: 40px; 54 | } 55 | #stripe-card-elements > div { 56 | transition: height 0.3s ease-out, padding-top 0.3s ease-out, padding-bottom 0.3s ease-out; 57 | } 58 | } 59 | 60 | @media only screen and (min-width: 999px) { 61 | #stripe-card-elements { 62 | display: flex; 63 | flex-wrap: wrap; 64 | } 65 | .stripe-card-holder { 66 | flex-grow: 1; 67 | } 68 | #stripe-card-elements > div.hidden { 69 | width: 0; 70 | padding: 0; 71 | overflow: hidden; 72 | display: block !important; 73 | } 74 | #stripe-card-elements > div { 75 | transition: width 0.3s ease-out, padding-left 0.3s ease-out, padding-right 0.3s ease-out; 76 | } 77 | } 78 | 79 | .vcenter { 80 | margin: auto; 81 | } 82 | -------------------------------------------------------------------------------- /eventyay_stripe/tasks.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from urllib.parse import urlsplit 3 | 4 | import stripe 5 | from django.conf import settings 6 | 7 | from pretix.base.services.tasks import EventTask 8 | from pretix.celery_app import app 9 | from pretix.multidomain.urlreverse import get_event_domain 10 | 11 | from .models import RegisteredApplePayDomain 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | def get_domain_for_event(event): 17 | domain = get_event_domain(event, fallback=True) 18 | if not domain: 19 | siteurlsplit = urlsplit(settings.SITE_URL) 20 | return siteurlsplit.hostname 21 | return domain 22 | 23 | 24 | def get_stripe_account_key(prov): 25 | if prov.settings.connect_user_id: 26 | return prov.settings.connect_user_id 27 | else: 28 | return prov.settings.publishable_key 29 | 30 | 31 | @app.task(base=EventTask, max_retries=5, default_retry_delay=1) 32 | def stripe_verify_domain(event, domain): 33 | from .payment import StripeCreditCard 34 | 35 | prov = StripeCreditCard(event) 36 | account = get_stripe_account_key(prov) 37 | 38 | api_config = { 39 | 'api_key': prov.settings.connect_secret_key or prov.settings.connect_test_secret_key 40 | if prov.settings.connect_client_id and prov.settings.connect_user_id 41 | else prov.settings.secret_key 42 | } 43 | 44 | if prov.settings.connect_client_id and prov.settings.connect_user_id: 45 | api_config['stripe_account'] = prov.settings.connect_user_id 46 | 47 | if RegisteredApplePayDomain.objects.filter(account=account, domain=domain).exists(): 48 | return 49 | 50 | try: 51 | resp = stripe.ApplePayDomain.create(domain_name=domain, **api_config) 52 | except stripe.error.StripeError: 53 | logger.exception('Could not verify domain with Stripe') 54 | else: 55 | if resp.livemode: 56 | RegisteredApplePayDomain.objects.create(domain=domain, account=account) 57 | -------------------------------------------------------------------------------- /eventyay_stripe/validation_models.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Union 2 | 3 | from pydantic import BaseModel, model_validator 4 | 5 | 6 | class CardDetails(BaseModel): 7 | brand: Optional[str] = None 8 | last4: Optional[str] = None 9 | exp_month: Optional[int] = None 10 | exp_year: Optional[int] = None 11 | 12 | 13 | class IdealDetails(BaseModel): 14 | bank: Optional[str] = None 15 | 16 | 17 | class BancontactDetails(BaseModel): 18 | bankname: Optional[str] = None 19 | 20 | 21 | class SofortDetails(BaseModel): 22 | country: Optional[str] = None 23 | iban_last4: Optional[str] = None 24 | bank_name: Optional[str] = None 25 | 26 | 27 | class EPSDetails(BaseModel): 28 | bank: Optional[str] = None 29 | 30 | 31 | class P24Details(BaseModel): 32 | bank: Optional[str] = None 33 | 34 | 35 | class PaymentMethodDetails(BaseModel): 36 | card: Optional[CardDetails] = None 37 | ideal: Optional[IdealDetails] = None 38 | bancontact: Optional[BancontactDetails] = None 39 | sofort: Optional[SofortDetails] = None 40 | eps: Optional[EPSDetails] = None 41 | p24: Optional[P24Details] = None 42 | 43 | 44 | class LatestCharge(BaseModel): 45 | payment_method_details: Optional[PaymentMethodDetails] = None 46 | 47 | 48 | class Source(BaseModel): 49 | card: Optional[CardDetails] = None 50 | ideal: Optional[IdealDetails] = None 51 | bancontact: Optional[BancontactDetails] = None 52 | sofort: Optional[SofortDetails] = None 53 | eps: Optional[EPSDetails] = None 54 | p24: Optional[P24Details] = None 55 | 56 | 57 | class PaymentInfoData(BaseModel): 58 | latest_charge: Optional[Union[str, LatestCharge]] = None 59 | source: Optional[Source] = None 60 | 61 | @model_validator(mode="before") 62 | def check_latest_charge_or_source(cls, values): 63 | latest_charge = values.get("latest_charge") 64 | source = values.get("source") 65 | if not latest_charge and not source: 66 | raise ValueError("Either 'latest_charge' or 'source' must be provided.") 67 | return values 68 | -------------------------------------------------------------------------------- /eventyay_stripe/templates/plugins/stripe/sca.html: -------------------------------------------------------------------------------- 1 | {% extends "pretixpresale/event/base.html" %} 2 | {% load i18n %} 3 | {% load eventurl %} 4 | {% load static %} 5 | {% block title %}{% trans "Pay order" %}{% endblock %} 6 | {% block custom_header %} 7 | {{ block.super }} 8 | {% include "plugins/stripe/presale_head.html" with settings=stripe_settings %} 9 | 10 | 11 | {% if payment_intent_next_action_redirect_url %} 12 | 15 | {% endif %} 16 | {% if payment_intent_redirect_action_handling %} 17 | 20 | {% endif %} 21 | {% endblock %} 22 | {% block content %} 23 |
24 |
25 |

26 | {% blocktrans trimmed with code=order.code %} 27 | Confirm payment: {{ code }} 28 | {% endblocktrans %} 29 |

30 |
31 |
32 |
33 |
34 | 35 |
36 |
37 | 53 | {% endblock %} 54 | -------------------------------------------------------------------------------- /eventyay_stripe/templates/plugins/stripe/pending.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% load eventurl %} 3 | 4 | {% if payment_info.next_action.type == "multibanco_display_details" %} 5 |
  • 6 | {% blocktrans trimmed %} 7 | In your online bank account or from an ATM, choose "Payment and other services". 8 | {% endblocktrans %} 9 |
  • 10 | {% else %} 11 |

    12 | {% endif %} 13 | 14 | 15 | {% if payment.state == "pending" %} 16 | {% if payment_info.next_action.type == "multibanco_display_details" %} 17 |
  • 18 | {% blocktrans trimmed %} 19 | In your online bank account or from an ATM, choose "Payment and other services". 20 | {% endblocktrans %} 21 |
  • 22 | {% else %} 23 |

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

    27 | {% endif %} 28 | {% elif payment.state == "created" and payment_info.status == "requires_action" %} 29 |

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

    38 |
    39 |

    40 | {% elif payment.state == "created" and payment.provider == "stripe_wechatpay" %} 41 |

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

    45 |
    46 | 47 |
    48 | {% else %} 49 |

    {% blocktrans trimmed %} 50 | The payment transaction could not be completed for the following reason: 51 | {% endblocktrans %} 52 |
    53 | {% if payment_info and payment_info.error %} 54 | {{ payment_info.message }} 55 | {% else %} 56 | {% trans "Unknown reason" %} 57 | {% endif %} 58 |

    59 | {% endif %} 60 | -------------------------------------------------------------------------------- /eventyay_stripe/tests/test_settings.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pytest 4 | 5 | from pretix.base.models import Event, Organizer, Team, User 6 | 7 | valid_secret_key_values = [ 8 | 'sk_', 9 | 'sk_foo', 10 | 'rk_bla', 11 | ] 12 | 13 | valid_publishable_key_values = [ 14 | 'pk_', 15 | 'pk_foo', 16 | ] 17 | 18 | invalid_secret_key_values = [ 19 | 'skihaspartialprefix', 20 | 'ihasnoprefix', 21 | 'ihaspostfixsk_', 22 | ] 23 | 24 | invalid_publishable_key_values = [ 25 | 'pkihaspartialprefix', 26 | 'ihasnoprefix', 27 | 'ihaspostfixpk_', 28 | ] 29 | 30 | 31 | @pytest.fixture 32 | def env(client): 33 | orga = Organizer.objects.create(name='CCC', slug='ccc') 34 | event = Event.objects.create( 35 | organizer=orga, name='30C3', slug='30c3', 36 | date_from=datetime.datetime(2013, 12, 26, tzinfo=datetime.timezone.utc), 37 | plugins='pretix.plugins.eventyay_stripe', 38 | live=True 39 | ) 40 | event.settings.set('attendee_names_asked', False) 41 | event.settings.set('payment_stripe__enabled', True) 42 | user = User.objects.create_user('dummy@dummy.dummy', 'dummy') 43 | t = Team.objects.create(organizer=event.organizer, can_change_event_settings=True) 44 | t.members.add(user) 45 | t.limit_events.add(event) 46 | client.force_login(user) 47 | url = '/control/event/%s/%s/settings/payment/stripe_settings' % (event.organizer.slug, event.slug) 48 | return client, event, url 49 | 50 | 51 | @pytest.mark.django_db 52 | def test_settings(env): 53 | client, event, url = env 54 | response = client.get(url, follow=True) 55 | assert response.status_code == 200 56 | assert 'stripe__enabled' in response.rendered_content 57 | 58 | 59 | def _stripe_key_test(env, field, value, is_valid): 60 | client, event, url = env 61 | data = {'payment_stripe_' + field: value, 'payment_stripe__enabled': 'on'} 62 | response = client.post(url, data, follow=True) 63 | 64 | if not is_valid: 65 | assert 'does not look valid' in response.rendered_content 66 | else: 67 | assert 'does not look valid' not in response.rendered_content 68 | 69 | 70 | @pytest.mark.django_db 71 | @pytest.mark.parametrize("value", invalid_secret_key_values) 72 | def test_settings_secret_key_invalid(env, value): 73 | _stripe_key_test(env, 'secret_key', value, False) 74 | 75 | 76 | @pytest.mark.django_db 77 | @pytest.mark.parametrize("value", invalid_publishable_key_values) 78 | def test_settings_publishable_key_invalid(env, value): 79 | _stripe_key_test(env, 'publishable_key', value, False) 80 | 81 | 82 | @pytest.mark.django_db 83 | @pytest.mark.parametrize("value", valid_secret_key_values) 84 | def test_settings_secret_key_valid(env, value): 85 | _stripe_key_test(env, 'secret_key', value, True) 86 | 87 | 88 | @pytest.mark.django_db 89 | @pytest.mark.parametrize("value", valid_publishable_key_values) 90 | def test_settings_publishable_key_valid(env, value): 91 | _stripe_key_test(env, 'publishable_key', value, True) 92 | -------------------------------------------------------------------------------- /eventyay_stripe/static/plugins/stripe/stripe_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 41 | 46 | 49 | 53 | 57 | 61 | 68 | 72 | 76 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /eventyay_stripe/tests/test_checkout.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pytest 4 | from django.utils.timezone import now 5 | 6 | from pretix.base.models import ( 7 | CartPosition, Event, Item, ItemCategory, Organizer, Quota, 8 | ) 9 | from pretix.testutils.sessions import add_cart_session, get_cart_session_key 10 | 11 | 12 | class MockedCharge(): 13 | status = '' 14 | paid = False 15 | id = 'ch_123345345' 16 | 17 | def refresh(self): 18 | pass 19 | 20 | 21 | class Object(): 22 | pass 23 | 24 | 25 | class MockedPaymentintent(): 26 | status = '' 27 | id = 'pi_1EUon12Tb35ankTnZyvC3SdE' 28 | charges = Object() 29 | charges.data = [MockedCharge()] 30 | last_payment_error = None 31 | 32 | 33 | @pytest.fixture 34 | def env(client): 35 | orga = Organizer.objects.create(name='CCC', slug='ccc') 36 | event = Event.objects.create( 37 | organizer=orga, name='30C3', slug='30c3', 38 | date_from=datetime.datetime(now().year + 1, 12, 26, tzinfo=datetime.timezone.utc), 39 | plugins='pretix.plugins.stripe', 40 | live=True 41 | ) 42 | category = ItemCategory.objects.create(event=event, name="Everything", position=0) 43 | quota_tickets = Quota.objects.create(event=event, name='Tickets', size=5) 44 | ticket = Item.objects.create(event=event, name='Early-bird ticket', 45 | category=category, default_price=23, admission=True) 46 | quota_tickets.items.add(ticket) 47 | event.settings.set('attendee_names_asked', False) 48 | event.settings.set('payment_stripe__enabled', True) 49 | add_cart_session(client, event, {'email': 'admin@localhost'}) 50 | return client, ticket 51 | 52 | 53 | @pytest.mark.django_db 54 | def test_payment(env, monkeypatch): 55 | def paymentintent_create(**kwargs): 56 | assert kwargs['amount'] == 1337 57 | assert kwargs['currency'] == 'eur' 58 | assert kwargs['payment_method'] == 'pm_189fTT2eZvKYlo2CvJKzEzeu' 59 | c = MockedPaymentintent() 60 | c.status = 'succeeded' 61 | c.charges.data[0].paid = True 62 | setattr(paymentintent_create, 'called', True) 63 | return c 64 | 65 | monkeypatch.setattr("stripe.PaymentIntent.create", paymentintent_create) 66 | 67 | client, ticket = env 68 | session_key = get_cart_session_key(client, ticket.event) 69 | CartPosition.objects.create( 70 | event=ticket.event, cart_id=session_key, item=ticket, 71 | price=13.37, expires=now() + datetime.timedelta(minutes=10) 72 | ) 73 | client.get('/%s/%s/checkout/payment/' % (ticket.event.organizer.slug, ticket.event.slug), follow=True) 74 | client.post('/%s/%s/checkout/questions/' % (ticket.event.organizer.slug, ticket.event.slug), { 75 | 'email': 'admin@localhost' 76 | }, follow=True) 77 | paymentintent_create.called = False 78 | response = client.post('/%s/%s/checkout/payment/' % (ticket.event.organizer.slug, ticket.event.slug), { 79 | 'payment': 'stripe', 80 | 'payment_method': 'pm_189fTT2eZvKYlo2CvJKzEzeu', 81 | 'stripe_card_brand': 'visa', 82 | 'stripe_card_last4': '1234' 83 | }, follow=True) 84 | assert not paymentintent_create.called 85 | assert response.status_code == 200 86 | assert 'alert-danger' not in response.rendered_content 87 | response = client.post('/%s/%s/checkout/confirm/' % (ticket.event.organizer.slug, ticket.event.slug), { 88 | }, follow=True) 89 | assert response.status_code == 200 90 | -------------------------------------------------------------------------------- /eventyay_stripe/templates/plugins/stripe/checkout_payment_form_cc.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 |
    4 | {% if is_moto %} 5 |

    6 | MOTO 7 |

    8 |
    9 | {% endif %} 10 | 11 |
    12 | 13 |
    14 | 19 | 20 | {% if request.session.payment_stripe_payment_method_id %} 21 |
    22 |

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

    25 |
    26 |
    {% trans "Card type" %}
    27 |
    {{ request.session.payment_stripe_brand }}
    28 |
    {% trans "Card number" %}
    29 |
    30 | **** **** **** 31 | {{ request.session.payment_stripe_last4 }} 32 | 35 |
    36 |
    37 |
    38 | {% endif %} 39 | 40 |
    41 |
    42 |
    43 | 44 | 45 |
    46 |
    47 | 54 | 60 |
    61 | 62 |

    63 | {% blocktrans trimmed %} 64 | Your payment will be processed by Stripe, Inc. Your credit card data will be transmitted directly to 65 | Stripe and never touches our servers. 66 | {% endblocktrans %} 67 | 68 | 69 | 71 | 73 | 74 |

    75 |
    76 | -------------------------------------------------------------------------------- /eventyay_stripe/templates/plugins/stripe/control.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% load money %} 3 | 4 | {% if payment_info %} 5 |
    6 | {% if "id" in payment_info %} 7 |
    {% trans "Charge ID" %}
    8 |
    {{ payment_info.id }}
    9 | {% endif %} 10 | {% if "source" in payment_info %} 11 | {% if payment_info.source.card %} 12 |
    {% trans "Card type" %}
    13 |
    {{ payment_info.source.card.brand }}
    14 |
    {% trans "Card number" %}
    15 |
    **** **** **** {{ payment_info.source.card.last4 }}
    16 | {% if payment_info.source.owner.name %} 17 |
    {% trans "Payer name" %}
    18 |
    {{ payment_info.source.owner.name }}
    19 | {% endif %} 20 | {% endif %} 21 | {% if payment_info.source.type == "bancontact" %} 22 |
    {% trans "Bank" %}
    23 |
    {{ payment_info.source.bancontact.bank_name }} ({{ payment_info.source.bancontact.bic }})
    24 |
    {% trans "Payer name" %}
    25 |
    {{ payment_info.source.owner.verified_name|default:payment_info.source.owner.name }}
    26 | {% endif %} 27 | {% if payment_info.source.type == "ideal" %} 28 |
    {% trans "Bank" %}
    29 |
    {{ payment_info.source.ideal.bank }} ({{ payment_info.source.ideal.bic }})
    30 |
    {% trans "Payer name" %}
    31 |
    {{ payment_info.source.owner.verified_name|default:payment_info.source.owner.name }}
    32 | {% endif %} 33 | {% if details.type == "sepa_debit" %} 34 |
    {% trans "Bank" %}
    35 |
    {{ details.sepadirectdebit.bank_name }}
    36 | {% if details.sepadirectdebit.verified_name %} 37 |
    {% trans "Payer name" %}
    38 |
    {{ details.sepadirectdebit.verified_name }}
    39 | {% endif %} 40 | {% endif %} 41 | {% endif %} 42 | {% if payment_info.charges.data.0 %} 43 | {% if payment_info.charges.data.0.payment_method_details.card %} 44 |
    {% trans "Card type" %}
    45 |
    {{ payment_info.charges.data.0.payment_method_details.card.brand }}
    46 |
    {% trans "Card number" %}
    47 |
    48 | **** **** **** {{ payment_info.charges.data.0.payment_method_details.card.last4 }} 49 | {% if payment_info.charges.data.0.payment_method_details.card.moto %} 50 | {% trans "MOTO" %} 51 | {% endif %} 52 |
    53 | {% endif %} 54 | {% endif %} 55 | {% if "amount" in payment_info %} 56 |
    {% trans "Total value" %}
    57 |
    {{ payment_info.amount|floatformat:2 }}
    58 | {% endif %} 59 | {% if "currency" in payment_info %} 60 |
    {% trans "Currency" %}
    61 |
    {{ payment_info.currency|upper }}
    62 | {% endif %} 63 | {% if "status" in payment_info %} 64 |
    {% trans "Status" %}
    65 |
    {{ payment_info.status }}
    66 | {% endif %} 67 | {% if "message" in payment_info %} 68 |
    {% trans "Error message" %}
    69 |
    {{ payment_info.message }}
    70 | {% endif %} 71 |
    72 | {% endif %} 73 | -------------------------------------------------------------------------------- /eventyay_stripe/templates/plugins/stripe/sepadirectdebit.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% load bootstrap3 %} 3 | 4 | {% if explanation %} 5 |

    {{ explanation }}

    6 | {% endif %} 7 |
    8 |
    9 | 10 |
    11 | 16 | 17 | {% if request.session.payment_stripe_sepa_debit_payment_method_id %} 18 |
    19 |

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

    22 |
    23 |
    {% trans "Banking Institution" %}
    24 |
    {{ request.session.payment_stripe_sepa_debit_bank }}
    25 |
    {% trans "Account number" %}
    26 |
    27 | **** **** **** 28 | {{ request.session.payment_stripe_sepa_debit_last4 }} 29 | 32 |
    33 |
    34 |
    35 | {% endif %} 36 |
    37 |
    38 | 39 |
    40 |
    41 |
    42 |
    43 | 44 | 45 |
    46 |
    47 |
    48 |
    49 |
    50 | {% bootstrap_form form layout='horizontal' %} 51 |
    52 | 53 |

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

    70 |
    71 | -------------------------------------------------------------------------------- /docs/Readme.md: -------------------------------------------------------------------------------- 1 | # Stripe Payment Settings Instruction 2 | 3 | This guide will walk you through obtaining the necessary keys to integrate Stripe with your application. 4 | To get started, you'll need an active Stripe merchant account. If you don't have one yet, you can sign up at [Stripe](http://stripe.com/). 5 | 6 | --- 7 | 8 | ## Step 1: Configure Stripe OAuth and Retrieve Client ID 9 | 10 | You can refer to the [Stripe documentation](https://docs.stripe.com/connect/oauth-standard-accounts#integrating-oauth) or follow the steps below: 11 | 12 | 1. Go to [Stripe Dashboard](https://dashboard.stripe.com/). 13 | 2. Select **Settings** in the upper-right corner of the Stripe dashboard. 14 | 3. In **Product settings** section, select the **Connect**. 15 | 16 | ![setting-dashboard.png](images/setting-dashboard.png) 17 | 18 | 4. In **Onboard connected accounts** section, click **Onboarding options**. 19 | 20 | ![connect-setting.png](images/connect-setting.png) 21 | 22 | 5. In the **OAuth** tab: 23 | 24 | ![oauth.png](images/oauth.png) 25 | 26 | - Enable OAuth (if disabled). 27 | - Add the URI for the Stripe OAuth flow (e.g, ```https:///_stripe/oauth_return/```). 28 | 29 | ![add-uri.png](images/add-uri.png) 30 | 31 | - Copy Client ID (Test client ID if you are in test mode). 32 | 33 | ![client-id.png](images/client-id.png) 34 | 35 | --- 36 | 37 | ## Step 2: Retrieve Secret Key and Publishable Key 38 | 39 | You can also refer to the [Stripe documentation](https://docs.stripe.com/keys) or follow the steps below: 40 | 41 | 1. Go to [Stripe Dashboard](https://dashboard.stripe.com/). 42 | 2. Log in with your credentials. 43 | 3. In the Dashboard, navigate to: 44 | - Click **Developers** in the left menu → Click **API keys**. 45 | 4. You will see two keys: 46 | - **Publishable Key**: Starts with `pk_`. 47 | - **Secret Key**: Starts with `sk_`. 48 | 49 | ![apikeys.png](images/apikeys.png) 50 | 51 | 5. Click **Reveal test key** or **Reveal live key** to see the **Secret Key** (depending on your mode): 52 | - **Test Mode**: For development and testing purposes. 53 | - **Live Mode**: For production use. 54 | 55 | 6. Copy both keys and store them securely. 56 | 57 | --- 58 | 59 | ## Step 3: Retrieve Webhook Signing Secret 60 | 61 | You can also refer to the [Stripe documentation](https://docs.stripe.com/webhooks) or follow the steps below: 62 | 63 | 1. In the Dashboard, go to: 64 | - Click Developers in the left menu → Click [Webhooks](https://dashboard.stripe.com/webhooks). 65 | 2. Click **Create an event destination**. 66 | 67 | ![webhook-create-event-dest.png](images/webhook-create-event-dest.png) 68 | 69 | 3. Choose the event types: 70 | - **Endpoint URL**: Enter the URL in your application that will handle the webhook (e.g., ```https://yourdomain.com/_stripe/webhook```). 71 | - **Description:** Add optional description of the destination. 72 | - **Listen to**: Select **Events on Connected accounts**. 73 | - **Select events to listen to**: Choose the following events: 74 | - `charge.succeeded` 75 | - `charge.failed` 76 | - `charge.refunded` 77 | - `charge.updated` 78 | - `charge.dispute.created` 79 | - `charge.dispute.updated` 80 | - `charge.dispute.closed` 81 | - `source.chargeable` 82 | - `source.failed` 83 | - `source.canceled` 84 | - `payment_intent.succeeded` 85 | - `payment_intent.payment_failed` 86 | - `payment_intent.canceled` 87 | - `payment_intent.processing` 88 | - Click **Add endpoint**. 89 | 90 | ![webhook-select-events.png](images/webhook-select-events.png) 91 | 92 | 4. Retrieve the signing secret: 93 | 94 | ![webhook-signing-key.png](images/webhook-signing-key.png) 95 | 96 | - Click **Reveal** under **Signing secret**. 97 | 98 | - Copy the **Signing secret** (starts with `whsec_`). 99 | 100 | --- 101 | 102 | ## Step 4: Configure Stripe Keys to Eventyay Global Settings 103 | 104 | 1. Log in to eventyay as an admin user. 105 | 2. Access to eventyay admin dashboard. 106 | 3. In the left menu, click to **Global settings**. 107 | 108 | ![global-settings.png](images/global-settings.png) 109 | 110 | 4. Scroll to bottom of the page and fill in the Stripe configuration fields: 111 | 112 | ![global-settings-capture.png](images/global-settings-capture.png) 113 | 114 | - **Stripe Connect: Client ID**: `[Client ID from Step 1]` 115 | - **Stripe Connect: Secret key**: `[Secret key from Step 2 (live mode)]` 116 | - **Stripe Connect: Publishable key**: `[Publishable key from Step 2 (live mode)]` 117 | - **Stripe Connect: Secret key (test)**: `[Secret key from Step 2 (test mode)]` 118 | - **Stripe Connect: Publishable key (test)**: `[Publishable key from Step 2 (test mode)]` 119 | - **Stripe Webhook: Signing secret**: `[Webhook signing secret from Step 3]` 120 | - Click **Save**. 121 | 122 | --- 123 | 124 | ### Notes 125 | 126 | 1. Ensure the Stripe payment plugin is installed for Stripe settings to appear in the Eventyay admin dashboard. 127 | 2. Certain Stripe payment methods must be enabled on [organizer’s Stripe account](https://dashboard.stripe.com/settings/payments) before configuring them in Eventyay. 128 | 3. If your Stripe dashboard appears different, you might have Workbench enabled under [Developer Settings](https://dashboard.stripe.com/settings/developers). Refer to the [Stripe documentation](https://docs.stripe.com/webhooks) for updated instructions to retrieve the webhook signing secret. 129 | -------------------------------------------------------------------------------- /eventyay_stripe/signals.py: -------------------------------------------------------------------------------- 1 | import json 2 | from collections import OrderedDict 3 | 4 | from django import forms 5 | from django.dispatch import receiver 6 | from django.template.loader import get_template 7 | from django.urls import resolve, reverse 8 | from django.utils.translation import gettext_lazy as _ 9 | 10 | from pretix.base.forms import SecretKeySettingsField 11 | from pretix.base.settings import settings_hierarkey 12 | from pretix.base.signals import ( 13 | logentry_display, register_global_settings, register_payment_providers, 14 | ) 15 | from pretix.control.signals import nav_organizer 16 | from pretix.presale.signals import html_head 17 | 18 | from .forms import StripeKeyValidator 19 | 20 | 21 | @receiver(register_payment_providers, dispatch_uid="payment_stripe") 22 | def register_payment_provider(sender, **kwargs): 23 | from .payment import ( 24 | StripeAffirm, StripeAlipay, StripeBancontact, StripeCreditCard, 25 | StripeEPS, StripeIdeal, StripeKlarna, StripeMobilePay, 26 | StripeMultibanco, StripePayPal, StripePrzelewy24, StripeRevolutPay, 27 | StripeSEPADirectDebit, StripeSettingsHolder, StripeSofort, StripeSwish, 28 | StripeTwint, StripeWeChatPay, 29 | ) 30 | 31 | return [ 32 | StripeSettingsHolder, StripeCreditCard, StripeIdeal, StripeAlipay, StripeBancontact, 33 | StripeSofort, StripeEPS, StripeMultibanco, StripePrzelewy24, StripeWeChatPay, 34 | StripePayPal, StripeRevolutPay, StripeSEPADirectDebit, StripeSwish, StripeTwint, 35 | StripeMobilePay, StripeAffirm, StripeKlarna 36 | ] 37 | 38 | 39 | @receiver(html_head, dispatch_uid="payment_stripe_html_head") 40 | def html_head_presale(sender, request=None, **kwargs): 41 | from .payment import StripeSettingsHolder 42 | 43 | provider = StripeSettingsHolder(sender) 44 | url = resolve(request.path_info) 45 | if provider.settings.get('_enabled', as_type=bool) and ("checkout" in url.url_name or "order.pay" in url.url_name): 46 | template = get_template('plugins/stripe/presale_head.html') 47 | ctx = { 48 | 'event': sender, 49 | 'settings': provider.settings, 50 | 'testmode': ( 51 | (provider.settings.get('endpoint', 'live') == 'test' or sender.testmode) 52 | and provider.settings.publishable_test_key 53 | ) 54 | } 55 | return template.render(ctx) 56 | else: 57 | return "" 58 | 59 | 60 | @receiver(signal=logentry_display, dispatch_uid="stripe_logentry_display") 61 | def pretixcontrol_logentry_display(sender, logentry, **kwargs): 62 | if logentry.action_type != 'pretix.plugins.stripe.event': 63 | return 64 | 65 | data = json.loads(logentry.data) 66 | event_type = data.get('type') 67 | text = None 68 | plains = { 69 | 'charge.succeeded': _('Charge succeeded.'), 70 | 'charge.refunded': _('Charge refunded.'), 71 | 'charge.updated': _('Charge updated.'), 72 | 'charge.pending': _('Charge pending'), 73 | 'source.chargeable': _('Payment authorized.'), 74 | 'source.canceled': _('Payment authorization canceled.'), 75 | 'source.failed': _('Payment authorization failed.') 76 | } 77 | 78 | if event_type in plains: 79 | text = plains[event_type] 80 | elif event_type == 'charge.failed': 81 | text = _('Charge failed. Reason: {}').format(data['data']['object']['failure_message']) 82 | elif event_type == 'charge.dispute.created': 83 | text = _('Dispute created. Reason: {}').format(data['data']['object']['reason']) 84 | elif event_type == 'charge.dispute.updated': 85 | text = _('Dispute updated. Reason: {}').format(data['data']['object']['reason']) 86 | elif event_type == 'charge.dispute.closed': 87 | text = _('Dispute closed. Status: {}').format(data['data']['object']['status']) 88 | 89 | if text: 90 | return _('Stripe reported an event: {}').format(text) 91 | 92 | 93 | settings_hierarkey.add_default('payment_stripe_method_card', True, bool) 94 | settings_hierarkey.add_default('payment_stripe_reseller_moto', False, bool) 95 | 96 | 97 | @receiver(register_global_settings, dispatch_uid='stripe_global_settings') 98 | def register_global_settings(sender, **kwargs): 99 | return OrderedDict([ 100 | ('payment_stripe_connect_client_id', forms.CharField( 101 | label=_('Stripe Connect: Client ID'), 102 | required=False, 103 | validators=( 104 | StripeKeyValidator('ca_'), 105 | ), 106 | )), 107 | ('payment_stripe_connect_secret_key', SecretKeySettingsField( 108 | label=_('Stripe Connect: Secret key'), 109 | required=False, 110 | validators=( 111 | StripeKeyValidator(['sk_live_', 'rk_live_']), 112 | ), 113 | )), 114 | ('payment_stripe_connect_publishable_key', forms.CharField( 115 | label=_('Stripe Connect: Publishable key'), 116 | required=False, 117 | validators=( 118 | StripeKeyValidator('pk_live_'), 119 | ), 120 | )), 121 | ('payment_stripe_connect_test_secret_key', SecretKeySettingsField( 122 | label=_('Stripe Connect: Secret key (test)'), 123 | required=False, 124 | validators=( 125 | StripeKeyValidator(['sk_test_', 'rk_test_']), 126 | ), 127 | )), 128 | ('payment_stripe_connect_test_publishable_key', forms.CharField( 129 | label=_('Stripe Connect: Publishable key (test)'), 130 | required=False, 131 | validators=( 132 | StripeKeyValidator('pk_test_'), 133 | ), 134 | )), 135 | ('payment_stripe_connect_app_fee_percent', forms.DecimalField( 136 | label=_('Stripe Connect: App fee (percent)'), 137 | required=False, 138 | )), 139 | ('payment_stripe_connect_app_fee_max', forms.DecimalField( 140 | label=_('Stripe Connect: App fee (max)'), 141 | required=False, 142 | )), 143 | ('payment_stripe_connect_app_fee_min', forms.DecimalField( 144 | label=_('Stripe Connect: App fee (min)'), 145 | required=False, 146 | )), 147 | ]) 148 | 149 | 150 | @receiver(nav_organizer, dispatch_uid="stripe_nav_organizer") 151 | def nav_o(sender, request, organizer, **kwargs): 152 | if request.user.has_active_staff_session(request.session.session_key): 153 | url = resolve(request.path_info) 154 | return [{ 155 | 'label': _('Stripe Connect'), 156 | 'url': reverse('plugins:eventyay_stripe:settings.connect', kwargs={ 157 | 'organizer': request.organizer.slug 158 | }), 159 | 'parent': reverse('control:organizer.edit', kwargs={ 160 | 'organizer': request.organizer.slug 161 | }), 162 | 'active': 'settings.connect' in url.url_name, 163 | }] 164 | return [] 165 | -------------------------------------------------------------------------------- /eventyay_stripe/tests/test_provider.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import timedelta 3 | from decimal import Decimal 4 | 5 | import pytest 6 | import stripe 7 | from django.test import RequestFactory 8 | from django.utils.timezone import now 9 | from django_scopes import scope 10 | from stripe.error import APIConnectionError, CardError 11 | 12 | from eventyay_stripe import __version__ 13 | from eventyay_stripe.payment import StripeCreditCard 14 | from pretix.base.models import Event, Order, OrderRefund, Organizer 15 | from pretix.base.payment import PaymentException 16 | 17 | 18 | @pytest.fixture 19 | def env(): 20 | o = Organizer.objects.create(name='Dummy', slug='dummy') 21 | with scope(organizer=o): 22 | event = Event.objects.create( 23 | organizer=o, name='Dummy', slug='dummy', 24 | date_from=now(), live=True 25 | ) 26 | o1 = Order.objects.create( 27 | code='FOOBAR', event=event, email='dummy@dummy.test', 28 | status=Order.STATUS_PENDING, 29 | datetime=now(), expires=now() + timedelta(days=10), 30 | total=Decimal('13.37') 31 | ) 32 | yield event, o1 33 | 34 | 35 | @pytest.fixture(autouse=True) 36 | def no_messages(monkeypatch): 37 | # Patch out template rendering for performance improvements 38 | monkeypatch.setattr("django.contrib.messages.api.add_message", lambda *args, **kwargs: None) 39 | 40 | 41 | @pytest.fixture 42 | def factory(): 43 | return RequestFactory() 44 | 45 | 46 | class MockedRefunds(): 47 | pass 48 | 49 | 50 | class MockedCharge(): 51 | status = '' 52 | paid = False 53 | id = 'ch_123345345' 54 | refunds = MockedRefunds() 55 | 56 | def refresh(self): 57 | pass 58 | 59 | 60 | class Object(): 61 | pass 62 | 63 | 64 | class MockedPaymentintent(): 65 | status = '' 66 | id = 'pi_1EUon12Tb35ankTnZyvC3SdE' 67 | charges = Object() 68 | charges.data = [MockedCharge()] 69 | last_payment_error = None 70 | 71 | 72 | @pytest.mark.django_db 73 | def test_perform_success(env, factory, monkeypatch): 74 | event, order = env 75 | 76 | def paymentintent_create(**kwargs): 77 | assert kwargs['amount'] == 1337 78 | assert kwargs['currency'] == 'eur' 79 | assert kwargs['payment_method'] == 'pm_189fTT2eZvKYlo2CvJKzEzeu' 80 | c = MockedPaymentintent() 81 | c.status = 'succeeded' 82 | c.charges.data[0].paid = True 83 | return c 84 | 85 | monkeypatch.setattr("stripe.PaymentIntent.create", paymentintent_create) 86 | prov = StripeCreditCard(event) 87 | prov.init_api() 88 | 89 | # Verify Stripe API version and app info configuration 90 | assert stripe.api_version == "2024-11-20.acacia" 91 | assert stripe.app_info == { 92 | 'name': 'eventyay-stripe', 93 | 'version': __version__, 94 | 'url': 'https://github.com/fossasia/eventyay-stripe' 95 | } 96 | 97 | req = factory.post('/', { 98 | 'stripe_payment_method_id': 'pm_189fTT2eZvKYlo2CvJKzEzeu', 99 | 'stripe_last4': '4242', 100 | 'stripe_brand': 'Visa' 101 | }) 102 | req.session = {} 103 | prov.checkout_prepare(req, {}) 104 | assert 'payment_stripe_payment_method_id' in req.session 105 | payment = order.payments.create( 106 | provider='stripe_cc', amount=order.total 107 | ) 108 | prov.execute_payment(req, payment) 109 | order.refresh_from_db() 110 | assert order.status == Order.STATUS_PAID 111 | 112 | 113 | @pytest.mark.django_db 114 | def test_perform_success_zero_decimal_currency(env, factory, monkeypatch): 115 | event, order = env 116 | event.currency = 'JPY' 117 | event.save() 118 | 119 | def paymentintent_create(**kwargs): 120 | assert kwargs['amount'] == 13 121 | assert kwargs['currency'] == 'jpy' 122 | assert kwargs['payment_method'] == 'pm_189fTT2eZvKYlo2CvJKzEzeu' 123 | c = MockedPaymentintent() 124 | c.status = 'succeeded' 125 | c.charges.data[0].paid = True 126 | return c 127 | 128 | monkeypatch.setattr("stripe.PaymentIntent.create", paymentintent_create) 129 | prov = StripeCreditCard(event) 130 | req = factory.post('/', { 131 | 'stripe_payment_method_id': 'pm_189fTT2eZvKYlo2CvJKzEzeu', 132 | 'stripe_last4': '4242', 133 | 'stripe_brand': 'Visa' 134 | }) 135 | req.session = {} 136 | prov.checkout_prepare(req, {}) 137 | assert 'payment_stripe_payment_method_id' in req.session 138 | payment = order.payments.create( 139 | provider='stripe_cc', amount=order.total 140 | ) 141 | prov.execute_payment(req, payment) 142 | order.refresh_from_db() 143 | assert order.status == Order.STATUS_PAID 144 | 145 | 146 | @pytest.mark.django_db 147 | def test_perform_card_error(env, factory, monkeypatch): 148 | event, order = env 149 | 150 | def paymentintent_create(**kwargs): 151 | raise CardError(message='Foo', param='foo', code=100) 152 | 153 | monkeypatch.setattr("stripe.PaymentIntent.create", paymentintent_create) 154 | prov = StripeCreditCard(event) 155 | req = factory.post('/', { 156 | 'stripe_payment_method_id': 'pm_189fTT2eZvKYlo2CvJKzEzeu', 157 | 'stripe_last4': '4242', 158 | 'stripe_brand': 'Visa' 159 | }) 160 | req.session = {} 161 | prov.checkout_prepare(req, {}) 162 | assert 'payment_stripe_payment_method_id' in req.session 163 | with pytest.raises(PaymentException): 164 | payment = order.payments.create( 165 | provider='stripe_cc', amount=order.total 166 | ) 167 | prov.execute_payment(req, payment) 168 | order.refresh_from_db() 169 | assert order.status == Order.STATUS_PENDING 170 | 171 | 172 | @pytest.mark.django_db 173 | def test_perform_stripe_error(env, factory, monkeypatch): 174 | event, order = env 175 | 176 | def paymentintent_create(**kwargs): 177 | raise CardError(message='Foo', param='foo', code=100) 178 | 179 | monkeypatch.setattr("stripe.PaymentIntent.create", paymentintent_create) 180 | prov = StripeCreditCard(event) 181 | req = factory.post('/', { 182 | 'stripe_payment_method_id': 'pm_189fTT2eZvKYlo2CvJKzEzeu', 183 | 'stripe_last4': '4242', 184 | 'stripe_brand': 'Visa' 185 | }) 186 | req.session = {} 187 | prov.checkout_prepare(req, {}) 188 | assert 'payment_stripe_payment_method_id' in req.session 189 | with pytest.raises(PaymentException): 190 | payment = order.payments.create( 191 | provider='stripe_cc', amount=order.total 192 | ) 193 | prov.execute_payment(req, payment) 194 | order.refresh_from_db() 195 | assert order.status == Order.STATUS_PENDING 196 | 197 | 198 | @pytest.mark.django_db 199 | def test_perform_failed(env, factory, monkeypatch): 200 | event, order = env 201 | 202 | def paymentintent_create(**kwargs): 203 | assert kwargs['amount'] == 1337 204 | assert kwargs['currency'] == 'eur' 205 | assert kwargs['payment_method'] == 'pm_189fTT2eZvKYlo2CvJKzEzeu' 206 | c = MockedPaymentintent() 207 | c.status = 'failed' 208 | c.failure_message = 'Foo' 209 | c.charges.data[0].paid = True 210 | c.last_payment_error = Object() 211 | c.last_payment_error.message = "Foo" 212 | return c 213 | 214 | monkeypatch.setattr("stripe.PaymentIntent.create", paymentintent_create) 215 | prov = StripeCreditCard(event) 216 | req = factory.post('/', { 217 | 'stripe_payment_method_id': 'pm_189fTT2eZvKYlo2CvJKzEzeu', 218 | 'stripe_last4': '4242', 219 | 'stripe_brand': 'Visa' 220 | }) 221 | req.session = {} 222 | prov.checkout_prepare(req, {}) 223 | assert 'payment_stripe_payment_method_id' in req.session 224 | with pytest.raises(PaymentException): 225 | payment = order.payments.create( 226 | provider='stripe_cc', amount=order.total 227 | ) 228 | prov.execute_payment(req, payment) 229 | order.refresh_from_db() 230 | assert order.status == Order.STATUS_PENDING 231 | 232 | 233 | @pytest.mark.django_db 234 | def test_refund_success(env, factory, monkeypatch): 235 | event, order = env 236 | 237 | def charge_retr(*args, **kwargs): 238 | def refund_create(amount): 239 | r = MockedCharge() 240 | r.id = 'foo' 241 | r.status = 'succeeded' 242 | return r 243 | 244 | c = MockedCharge() 245 | c.refunds.create = refund_create 246 | return c 247 | 248 | monkeypatch.setattr("stripe.Charge.retrieve", charge_retr) 249 | order.status = Order.STATUS_PAID 250 | p = order.payments.create(provider='stripe_cc', amount=order.total, info=json.dumps({ 251 | 'id': 'ch_123345345' 252 | })) 253 | order.save() 254 | prov = StripeCreditCard(event) 255 | refund = order.refunds.create( 256 | provider='stripe_cc', amount=order.total, payment=p, 257 | ) 258 | prov.execute_refund(refund) 259 | refund.refresh_from_db() 260 | assert refund.state == OrderRefund.REFUND_STATE_DONE 261 | 262 | 263 | @pytest.mark.django_db 264 | def test_refund_unavailable(env, factory, monkeypatch): 265 | event, order = env 266 | 267 | def charge_retr(*args, **kwargs): 268 | def refund_create(amount): 269 | raise APIConnectionError(message='Foo') 270 | 271 | c = MockedCharge() 272 | c.refunds.create = refund_create 273 | return c 274 | 275 | monkeypatch.setattr("stripe.Charge.retrieve", charge_retr) 276 | order.status = Order.STATUS_PAID 277 | p = order.payments.create(provider='stripe_cc', amount=order.total, info=json.dumps({ 278 | 'id': 'ch_123345345' 279 | })) 280 | order.save() 281 | prov = StripeCreditCard(event) 282 | refund = order.refunds.create( 283 | provider='stripe_cc', amount=order.total, payment=p 284 | ) 285 | with pytest.raises(PaymentException): 286 | prov.execute_refund(refund) 287 | refund.refresh_from_db() 288 | assert refund.state != OrderRefund.REFUND_STATE_DONE 289 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /eventyay_stripe/tests/test_webhook.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import hmac 3 | import json 4 | import time 5 | from datetime import timedelta 6 | from decimal import Decimal 7 | from unittest import mock 8 | 9 | import pytest 10 | import stripe 11 | from django.test import RequestFactory 12 | from django.utils.timezone import now 13 | from django_scopes import scopes_disabled 14 | 15 | from eventyay_stripe.models import ReferencedStripeObject 16 | from eventyay_stripe.views import GlobalSettingsObject, webhook 17 | from pretix.base.models import ( 18 | Event, Order, OrderPayment, OrderRefund, Organizer, Team, User, 19 | ) 20 | 21 | 22 | @pytest.fixture 23 | def env(): 24 | user = User.objects.create_user('dummy@dummy.dummy', 'dummy') 25 | o = Organizer.objects.create(name='Dummy', slug='dummy') 26 | event = Event.objects.create( 27 | organizer=o, name='Dummy', slug='dummy', plugins='eventyay_stripe', 28 | date_from=now(), live=True 29 | ) 30 | t = Team.objects.create(organizer=event.organizer, can_view_orders=True, can_change_orders=True) 31 | t.members.add(user) 32 | t.limit_events.add(event) 33 | o1 = Order.objects.create( 34 | code='FOOBAR', event=event, email='dummy@dummy.test', 35 | status=Order.STATUS_PAID, 36 | datetime=now(), expires=now() + timedelta(days=10), 37 | total=Decimal('13.37'), 38 | ) 39 | return event, o1 40 | 41 | 42 | def generate_signature(payload, secret, timestamp=None): 43 | """Generate a valid Stripe webhook signature for testing.""" 44 | timestamp = timestamp or int(time.time()) 45 | signed_payload = f"{timestamp}.{payload}" 46 | signature = hmac.new( 47 | secret.encode("utf-8"), 48 | signed_payload.encode("utf-8"), 49 | hashlib.sha256 50 | ).hexdigest() 51 | return f"t={timestamp},v1={signature}" 52 | 53 | 54 | def get_test_charge(order: Order): 55 | return { 56 | "id": "ch_18TY6GGGWE2Is8TZHanef25", 57 | "object": "charge", 58 | "amount": 1337, 59 | "amount_refunded": 1000, 60 | "application_fee": None, 61 | "balance_transaction": "txn_18TY6GGGWE2Ias8TkwY6o51W", 62 | "captured": True, 63 | "created": 1467642664, 64 | "currency": "eur", 65 | "customer": None, 66 | "description": None, 67 | "destination": None, 68 | "dispute": None, 69 | "failure_code": None, 70 | "failure_message": None, 71 | "fraud_details": {}, 72 | "invoice": None, 73 | "livemode": False, 74 | "metadata": { 75 | "code": order.code, 76 | "order": str(order.pk), 77 | "event": str(order.event.pk), 78 | }, 79 | "order": None, 80 | "paid": True, 81 | "receipt_email": None, 82 | "receipt_number": None, 83 | "refunded": False, 84 | "refunds": { 85 | "object": "list", 86 | "data": [], 87 | "total_count": 0 88 | }, 89 | "shipping": None, 90 | "source": { 91 | "id": "card_18TY5wGGWE2Ias8Td38PjyPy", 92 | "object": "card", 93 | "address_city": None, 94 | "address_country": None, 95 | "address_line1": None, 96 | "address_line1_check": None, 97 | "address_line2": None, 98 | "address_state": None, 99 | "address_zip": None, 100 | "address_zip_check": None, 101 | "brand": "Visa", 102 | "country": "US", 103 | "customer": None, 104 | "cvc_check": "pass", 105 | "dynamic_last4": None, 106 | "exp_month": 12, 107 | "exp_year": 2016, 108 | "fingerprint": "FNbGTMaFvhRU2Y0E", 109 | "funding": "credit", 110 | "last4": "4242", 111 | "metadata": {}, 112 | "name": "Carl Cardholder", 113 | "tokenization_method": None, 114 | }, 115 | "source_transfer": None, 116 | "statement_descriptor": None, 117 | "status": "succeeded" 118 | } 119 | 120 | 121 | @pytest.mark.django_db 122 | def test_webhook_all_good(env, client, monkeypatch): 123 | charge = get_test_charge(env[1]) 124 | monkeypatch.setattr("stripe.Charge.retrieve", lambda *args, **kwargs: charge) 125 | 126 | client.post('/dummy/dummy/stripe/webhook/', json.dumps( 127 | { 128 | "id": "evt_18otImGGWE2Ias8TUyVRDB1G", 129 | "object": "event", 130 | "api_version": "2016-03-07", 131 | "created": 1472729052, 132 | "data": { 133 | "object": { 134 | "id": "ch_18TY6GGGWE2Ias8TZHanef25", 135 | "object": "charge", 136 | # Rest of object is ignored anway 137 | } 138 | }, 139 | "livemode": True, 140 | "pending_webhooks": 1, 141 | "request": "req_977XOWC8zk51Z9", 142 | "type": "charge.refunded" 143 | } 144 | ), content_type='application_json') 145 | 146 | order = env[1] 147 | order.refresh_from_db() 148 | assert order.status == Order.STATUS_PAID 149 | 150 | 151 | @pytest.mark.django_db 152 | def test_webhook_mark_paid(env, client, monkeypatch): 153 | order = env[1] 154 | order.status = Order.STATUS_PENDING 155 | order.save() 156 | charge = get_test_charge(env[1]) 157 | charge["amount_refunded"] = 0 158 | with scopes_disabled(): 159 | payment = env[1].payments.create( 160 | provider='stripe', amount=env[1].total, info='{}', state=OrderPayment.PAYMENT_STATE_CREATED, 161 | ) 162 | ReferencedStripeObject.objects.create( 163 | order=order, 164 | payment=payment, 165 | reference="pi_1", 166 | ) 167 | 168 | monkeypatch.setattr("stripe.Charge.retrieve", lambda *args, **kwargs: charge) 169 | 170 | client.post('/dummy/dummy/stripe/webhook/', json.dumps( 171 | { 172 | "id": "evt_18otImGGWE2Ias8TUyVRDB1G", 173 | "object": "event", 174 | "api_version": "2016-03-07", 175 | "created": 1472729052, 176 | "data": { 177 | "object": { 178 | "object": "charge", 179 | "id": "pi_1", 180 | "amount": 2000, 181 | "currency": "usd", 182 | "status": "succeeded", 183 | "livemode": True, 184 | "pending_webhooks": 1, 185 | "request": "req_977XOWC8zk51Z9", 186 | "type": "charge.succeeded" 187 | } 188 | }, 189 | "livemode": True, 190 | "pending_webhooks": 1, 191 | "request": "req_977XOWC8zk51Z9", 192 | "type": "payment_intent.succeeded" 193 | } 194 | ), content_type='application_json') 195 | 196 | order.refresh_from_db() 197 | assert order.status == Order.STATUS_PENDING 198 | 199 | 200 | @pytest.mark.django_db 201 | def test_webhook_partial_refund(env, client, monkeypatch): 202 | charge = get_test_charge(env[1]) 203 | 204 | with scopes_disabled(): 205 | payment = env[1].payments.create( 206 | provider='stripe', amount=env[1].total, info=json.dumps(charge) 207 | ) 208 | ReferencedStripeObject.objects.create(order=env[1], reference="ch_18TY6GGGWE2Ias8TZHanef25", 209 | payment=payment) 210 | 211 | charge['refunds'] = { 212 | "object": "list", 213 | "data": [ 214 | { 215 | "id": "re_18otImGGWE2Ias8TY0QvwKYQ", 216 | "object": "refund", 217 | "amount": "12300", 218 | "balance_transaction": "txn_18otImGGWE2Ias8T4fLOxesC", 219 | "charge": "ch_18TY6GGGWE2Ias8TZHanef25", 220 | "created": 1472729052, 221 | "currency": "eur", 222 | "metadata": {}, 223 | "reason": None, 224 | "receipt_number": None, 225 | "status": "succeeded" 226 | } 227 | ], 228 | "total_count": 1 229 | } 230 | monkeypatch.setattr("stripe.Charge.retrieve", lambda *args, **kwargs: charge) 231 | 232 | payload = json.dumps( 233 | { 234 | "id": "evt_18otImGGWE2Ias8TUyVRDB1G", 235 | "object": "event", 236 | "api_version": "2016-03-07", 237 | "created": 1472729052, 238 | "data": { 239 | "object": { 240 | "id": "ch_18TY6GGGWE2Ias8TZHanef25", 241 | "object": "charge", 242 | # Rest of object is ignored anway 243 | } 244 | }, 245 | "livemode": True, 246 | "pending_webhooks": 1, 247 | "request": "req_977XOWC8zk51Z9", 248 | "type": "charge.refunded" 249 | } 250 | ) 251 | 252 | gs = GlobalSettingsObject() 253 | gs.settings.set('payment_stripe_webhook_secret', 'whsec_123') 254 | gs.settings.set('payment_stripe_connect_test_secret_key', 'sk_test_123') 255 | 256 | sig_header = generate_signature(payload, "whsec_123") 257 | client.post( 258 | '/dummy/dummy/stripe/webhook/', 259 | payload, 260 | content_type='application_json', 261 | HTTP_STRIPE_SIGNATURE=sig_header 262 | ) 263 | 264 | order = env[1] 265 | order.refresh_from_db() 266 | assert order.status == Order.STATUS_PAID 267 | 268 | with scopes_disabled(): 269 | ra = order.refunds.first() 270 | assert ra.state == OrderRefund.REFUND_STATE_EXTERNAL 271 | assert ra.source == 'external' 272 | assert ra.amount == Decimal('123.00') 273 | 274 | 275 | @pytest.mark.django_db 276 | def test_webhook_global(env, client, monkeypatch): 277 | order = env[1] 278 | order.status = Order.STATUS_PENDING 279 | order.save() 280 | 281 | charge = get_test_charge(env[1]) 282 | charge["amount_refunded"] = 0 283 | monkeypatch.setattr("stripe.Charge.retrieve", lambda *args, **kwargs: charge) 284 | 285 | with scopes_disabled(): 286 | payment = order.payments.create( 287 | provider='stripe', amount=order.total, info=json.dumps(charge), state=OrderPayment.PAYMENT_STATE_CREATED 288 | ) 289 | ReferencedStripeObject.objects.create(order=order, reference="ch_18TY6GGGWE2Ias8TZHanef25", 290 | payment=payment) 291 | ReferencedStripeObject.objects.create(order=order, reference="pi_123456", 292 | payment=payment) 293 | 294 | payload = json.dumps( 295 | { 296 | "id": "evt_18otImGGWE2Ias8TUyVRDB1G", 297 | "object": "event", 298 | "api_version": "2016-03-07", 299 | "created": 1472729052, 300 | "data": { 301 | "object": { 302 | "id": "ch_18TY6GGGWE2Ias8TZHanef25", 303 | "object": "charge", 304 | "payment_intent": "pi_123456", 305 | "metadata": { 306 | "event": order.event_id, 307 | } 308 | } 309 | }, 310 | "livemode": True, 311 | "pending_webhooks": 1, 312 | "request": "req_977XOWC8zk51Z9", 313 | "type": "payment_intent.succeeded" 314 | } 315 | ) 316 | gs = GlobalSettingsObject() 317 | gs.settings.set('payment_stripe_webhook_secret', 'whsec_123') 318 | gs.settings.set('payment_stripe_connect_test_secret_key', 'sk_test_123') 319 | 320 | sig_header = generate_signature(payload, "whsec_123") 321 | response = client.post( 322 | '/_stripe/webhook/', 323 | payload, 324 | content_type='application_json', 325 | HTTP_STRIPE_SIGNATURE=sig_header 326 | ) 327 | assert response.status_code == 200 328 | 329 | order.refresh_from_db() 330 | assert order.status == Order.STATUS_PAID 331 | 332 | 333 | @pytest.mark.django_db 334 | def test_webhook_global_legacy_reference(env, client, monkeypatch): 335 | order = env[1] 336 | order.status = Order.STATUS_PENDING 337 | order.save() 338 | 339 | charge = get_test_charge(env[1]) 340 | charge["amount_refunded"] = 0 341 | monkeypatch.setattr("stripe.Charge.retrieve", lambda *args, **kwargs: charge) 342 | 343 | with scopes_disabled(): 344 | payment = order.payments.create( 345 | provider='stripe', amount=order.total, info=json.dumps(charge), state=OrderPayment.PAYMENT_STATE_CREATED 346 | ) 347 | ReferencedStripeObject.objects.create(order=order, reference="ch_18TY6GGGWE2Ias8TZHanef25") 348 | ReferencedStripeObject.objects.create(order=order, reference="pi_123456") 349 | 350 | payload = json.dumps( 351 | { 352 | "id": "evt_18otImGGWE2Ias8TUyVRDB1G", 353 | "object": "event", 354 | "api_version": "2016-03-07", 355 | "created": 1472729052, 356 | "data": { 357 | "object": { 358 | "id": "ch_18TY6GGGWE2Ias8TZHanef25", 359 | "object": "charge", 360 | "payment_intent": "pi_123456", 361 | "metadata": { 362 | "event": order.event_id, 363 | } 364 | } 365 | }, 366 | "livemode": True, 367 | "pending_webhooks": 1, 368 | "request": "req_977XOWC8zk51Z9", 369 | "type": "payment_intent.succeeded" 370 | } 371 | ) 372 | gs = GlobalSettingsObject() 373 | gs.settings.set('payment_stripe_webhook_secret', 'whsec_123') 374 | gs.settings.set('payment_stripe_connect_test_secret_key', 'sk_test_123') 375 | sig_header = generate_signature(payload, "whsec_123") 376 | 377 | response = client.post('/_stripe/webhook/', payload, content_type='application_json', HTTP_STRIPE_SIGNATURE=sig_header) 378 | assert response.status_code == 200 379 | 380 | order.refresh_from_db() 381 | assert order.status == Order.STATUS_PAID 382 | with scopes_disabled(): 383 | assert list(order.payments.all()) == [payment] 384 | 385 | 386 | @pytest.fixture 387 | def factory(): 388 | return RequestFactory() 389 | 390 | 391 | @pytest.fixture 392 | def mock_global_settings(): 393 | with mock.patch('eventyay_stripe.views.GlobalSettingsObject') as MockGlobalSettings: 394 | instance = MockGlobalSettings.return_value 395 | instance.settings.payment_stripe_connect_secret_key = 'sk_test_123' 396 | instance.settings.payment_stripe_connect_test_secret_key = 'sk_test_123' 397 | instance.settings.payment_stripe_webhook_secret = 'whsec_123' 398 | yield instance 399 | 400 | 401 | @pytest.fixture 402 | def valid_payload(): 403 | return json.dumps({ 404 | "id": "evt_1", 405 | "object": "event", 406 | "type": "payment_intent.succeeded", 407 | "data": { 408 | "object": { 409 | "object": "charge", 410 | "id": "pi_1", 411 | "amount": 2000, 412 | "currency": "usd", 413 | "status": "succeeded", 414 | "livemode": True, 415 | "pending_webhooks": 1, 416 | "request": "req_977XOWC8zk51Z9", 417 | "type": "charge.succeeded" 418 | } 419 | } 420 | }) 421 | 422 | 423 | @pytest.mark.django_db 424 | def test_webhook_invalid_payload(factory, mock_global_settings): 425 | invalid_payload = "invalid_payload" 426 | request = factory.post('/dummy/dummy/stripe/webhook', data=invalid_payload, content_type='application/json') 427 | with mock.patch('stripe.Webhook.construct_event', side_effect=ValueError("Invalid JSON")): 428 | response = webhook(request) 429 | assert response.status_code == 400 430 | assert response.content == b"Invalid payload" 431 | 432 | 433 | @pytest.mark.django_db 434 | def test_webhook_invalid_signature(factory, valid_payload, mock_global_settings): 435 | request = factory.post('/_stripe/webhook', data=valid_payload, content_type='application/json') 436 | request.META['HTTP_STRIPE_SIGNATURE'] = 'invalid_signature' 437 | 438 | with mock.patch('stripe.Webhook.construct_event', side_effect=stripe.error.SignatureVerificationError("Invalid signature", 'sig_123')): 439 | response = webhook(request) 440 | assert response.status_code == 400 441 | assert response.content == b"Invalid Stripe signature" 442 | 443 | 444 | @pytest.mark.django_db 445 | def test_webhook_success(factory, valid_payload, mock_global_settings): 446 | request = factory.post('/_stripe/webhook', data=valid_payload, content_type='application/json') 447 | request.META['HTTP_STRIPE_SIGNATURE'] = 'valid_signature' 448 | 449 | with mock.patch('stripe.Webhook.construct_event', return_value={"type": "payment_intent.succeeded"}): 450 | response = webhook(request) 451 | assert response.status_code == 200 452 | 453 | 454 | @pytest.mark.django_db 455 | def test_webhook_refund(factory, valid_payload, mock_global_settings): 456 | request = factory.post('/_stripe/webhook', data=valid_payload, content_type='application/json') 457 | request.META['HTTP_STRIPE_SIGNATURE'] = 'valid_signature' 458 | 459 | with mock.patch('stripe.Webhook.construct_event', return_value={"type": "payment_intent.succeeded"}): 460 | response = webhook(request) 461 | assert response.status_code == 200 462 | -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | requires-python = ">=3.9" 3 | 4 | [[package]] 5 | name = "certifi" 6 | version = "2025.1.31" 7 | source = { registry = "https://pypi.org/simple" } 8 | sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 } 9 | wheels = [ 10 | { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, 11 | ] 12 | 13 | [[package]] 14 | name = "charset-normalizer" 15 | version = "3.4.1" 16 | source = { registry = "https://pypi.org/simple" } 17 | sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 } 18 | wheels = [ 19 | { url = "https://files.pythonhosted.org/packages/0d/58/5580c1716040bc89206c77d8f74418caf82ce519aae06450393ca73475d1/charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de", size = 198013 }, 20 | { url = "https://files.pythonhosted.org/packages/d0/11/00341177ae71c6f5159a08168bcb98c6e6d196d372c94511f9f6c9afe0c6/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176", size = 141285 }, 21 | { url = "https://files.pythonhosted.org/packages/01/09/11d684ea5819e5a8f5100fb0b38cf8d02b514746607934134d31233e02c8/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037", size = 151449 }, 22 | { url = "https://files.pythonhosted.org/packages/08/06/9f5a12939db324d905dc1f70591ae7d7898d030d7662f0d426e2286f68c9/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f", size = 143892 }, 23 | { url = "https://files.pythonhosted.org/packages/93/62/5e89cdfe04584cb7f4d36003ffa2936681b03ecc0754f8e969c2becb7e24/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a", size = 146123 }, 24 | { url = "https://files.pythonhosted.org/packages/a9/ac/ab729a15c516da2ab70a05f8722ecfccc3f04ed7a18e45c75bbbaa347d61/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a", size = 147943 }, 25 | { url = "https://files.pythonhosted.org/packages/03/d2/3f392f23f042615689456e9a274640c1d2e5dd1d52de36ab8f7955f8f050/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247", size = 142063 }, 26 | { url = "https://files.pythonhosted.org/packages/f2/e3/e20aae5e1039a2cd9b08d9205f52142329f887f8cf70da3650326670bddf/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408", size = 150578 }, 27 | { url = "https://files.pythonhosted.org/packages/8d/af/779ad72a4da0aed925e1139d458adc486e61076d7ecdcc09e610ea8678db/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb", size = 153629 }, 28 | { url = "https://files.pythonhosted.org/packages/c2/b6/7aa450b278e7aa92cf7732140bfd8be21f5f29d5bf334ae987c945276639/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d", size = 150778 }, 29 | { url = "https://files.pythonhosted.org/packages/39/f4/d9f4f712d0951dcbfd42920d3db81b00dd23b6ab520419626f4023334056/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807", size = 146453 }, 30 | { url = "https://files.pythonhosted.org/packages/49/2b/999d0314e4ee0cff3cb83e6bc9aeddd397eeed693edb4facb901eb8fbb69/charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f", size = 95479 }, 31 | { url = "https://files.pythonhosted.org/packages/2d/ce/3cbed41cff67e455a386fb5e5dd8906cdda2ed92fbc6297921f2e4419309/charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f", size = 102790 }, 32 | { url = "https://files.pythonhosted.org/packages/72/80/41ef5d5a7935d2d3a773e3eaebf0a9350542f2cab4eac59a7a4741fbbbbe/charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125", size = 194995 }, 33 | { url = "https://files.pythonhosted.org/packages/7a/28/0b9fefa7b8b080ec492110af6d88aa3dea91c464b17d53474b6e9ba5d2c5/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1", size = 139471 }, 34 | { url = "https://files.pythonhosted.org/packages/71/64/d24ab1a997efb06402e3fc07317e94da358e2585165930d9d59ad45fcae2/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3", size = 149831 }, 35 | { url = "https://files.pythonhosted.org/packages/37/ed/be39e5258e198655240db5e19e0b11379163ad7070962d6b0c87ed2c4d39/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd", size = 142335 }, 36 | { url = "https://files.pythonhosted.org/packages/88/83/489e9504711fa05d8dde1574996408026bdbdbd938f23be67deebb5eca92/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00", size = 143862 }, 37 | { url = "https://files.pythonhosted.org/packages/c6/c7/32da20821cf387b759ad24627a9aca289d2822de929b8a41b6241767b461/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12", size = 145673 }, 38 | { url = "https://files.pythonhosted.org/packages/68/85/f4288e96039abdd5aeb5c546fa20a37b50da71b5cf01e75e87f16cd43304/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77", size = 140211 }, 39 | { url = "https://files.pythonhosted.org/packages/28/a3/a42e70d03cbdabc18997baf4f0227c73591a08041c149e710045c281f97b/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146", size = 148039 }, 40 | { url = "https://files.pythonhosted.org/packages/85/e4/65699e8ab3014ecbe6f5c71d1a55d810fb716bbfd74f6283d5c2aa87febf/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd", size = 151939 }, 41 | { url = "https://files.pythonhosted.org/packages/b1/82/8e9fe624cc5374193de6860aba3ea8070f584c8565ee77c168ec13274bd2/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6", size = 149075 }, 42 | { url = "https://files.pythonhosted.org/packages/3d/7b/82865ba54c765560c8433f65e8acb9217cb839a9e32b42af4aa8e945870f/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8", size = 144340 }, 43 | { url = "https://files.pythonhosted.org/packages/b5/b6/9674a4b7d4d99a0d2df9b215da766ee682718f88055751e1e5e753c82db0/charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b", size = 95205 }, 44 | { url = "https://files.pythonhosted.org/packages/1e/ab/45b180e175de4402dcf7547e4fb617283bae54ce35c27930a6f35b6bef15/charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76", size = 102441 }, 45 | { url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105 }, 46 | { url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404 }, 47 | { url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423 }, 48 | { url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184 }, 49 | { url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268 }, 50 | { url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601 }, 51 | { url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098 }, 52 | { url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520 }, 53 | { url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852 }, 54 | { url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488 }, 55 | { url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192 }, 56 | { url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550 }, 57 | { url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785 }, 58 | { url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698 }, 59 | { url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162 }, 60 | { url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263 }, 61 | { url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966 }, 62 | { url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992 }, 63 | { url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162 }, 64 | { url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972 }, 65 | { url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095 }, 66 | { url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668 }, 67 | { url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073 }, 68 | { url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732 }, 69 | { url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391 }, 70 | { url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702 }, 71 | { url = "https://files.pythonhosted.org/packages/7f/c0/b913f8f02836ed9ab32ea643c6fe4d3325c3d8627cf6e78098671cafff86/charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41", size = 197867 }, 72 | { url = "https://files.pythonhosted.org/packages/0f/6c/2bee440303d705b6fb1e2ec789543edec83d32d258299b16eed28aad48e0/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f", size = 141385 }, 73 | { url = "https://files.pythonhosted.org/packages/3d/04/cb42585f07f6f9fd3219ffb6f37d5a39b4fd2db2355b23683060029c35f7/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2", size = 151367 }, 74 | { url = "https://files.pythonhosted.org/packages/54/54/2412a5b093acb17f0222de007cc129ec0e0df198b5ad2ce5699355269dfe/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770", size = 143928 }, 75 | { url = "https://files.pythonhosted.org/packages/5a/6d/e2773862b043dcf8a221342954f375392bb2ce6487bcd9f2c1b34e1d6781/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4", size = 146203 }, 76 | { url = "https://files.pythonhosted.org/packages/b9/f8/ca440ef60d8f8916022859885f231abb07ada3c347c03d63f283bec32ef5/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537", size = 148082 }, 77 | { url = "https://files.pythonhosted.org/packages/04/d2/42fd330901aaa4b805a1097856c2edf5095e260a597f65def493f4b8c833/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496", size = 142053 }, 78 | { url = "https://files.pythonhosted.org/packages/9e/af/3a97a4fa3c53586f1910dadfc916e9c4f35eeada36de4108f5096cb7215f/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78", size = 150625 }, 79 | { url = "https://files.pythonhosted.org/packages/26/ae/23d6041322a3556e4da139663d02fb1b3c59a23ab2e2b56432bd2ad63ded/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7", size = 153549 }, 80 | { url = "https://files.pythonhosted.org/packages/94/22/b8f2081c6a77cb20d97e57e0b385b481887aa08019d2459dc2858ed64871/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6", size = 150945 }, 81 | { url = "https://files.pythonhosted.org/packages/c7/0b/c5ec5092747f801b8b093cdf5610e732b809d6cb11f4c51e35fc28d1d389/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294", size = 146595 }, 82 | { url = "https://files.pythonhosted.org/packages/0c/5a/0b59704c38470df6768aa154cc87b1ac7c9bb687990a1559dc8765e8627e/charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5", size = 95453 }, 83 | { url = "https://files.pythonhosted.org/packages/85/2d/a9790237cb4d01a6d57afadc8573c8b73c609ade20b80f4cda30802009ee/charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765", size = 102811 }, 84 | { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 }, 85 | ] 86 | 87 | [[package]] 88 | name = "eventyay-stripe" 89 | version = "1.0.1" 90 | source = { editable = "." } 91 | dependencies = [ 92 | { name = "stripe" }, 93 | ] 94 | 95 | [package.metadata] 96 | requires-dist = [{ name = "stripe", specifier = "==11.3.*" }] 97 | 98 | [[package]] 99 | name = "idna" 100 | version = "3.10" 101 | source = { registry = "https://pypi.org/simple" } 102 | sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } 103 | wheels = [ 104 | { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, 105 | ] 106 | 107 | [[package]] 108 | name = "requests" 109 | version = "2.32.3" 110 | source = { registry = "https://pypi.org/simple" } 111 | dependencies = [ 112 | { name = "certifi" }, 113 | { name = "charset-normalizer" }, 114 | { name = "idna" }, 115 | { name = "urllib3" }, 116 | ] 117 | sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } 118 | wheels = [ 119 | { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, 120 | ] 121 | 122 | [[package]] 123 | name = "stripe" 124 | version = "11.3.0" 125 | source = { registry = "https://pypi.org/simple" } 126 | dependencies = [ 127 | { name = "requests" }, 128 | { name = "typing-extensions" }, 129 | ] 130 | sdist = { url = "https://files.pythonhosted.org/packages/0d/96/358f9f62960826ee77de447d70e7ed09741dd5d53d684be1371913e51d7f/stripe-11.3.0.tar.gz", hash = "sha256:98e625d9ddbabcecf02666867169696e113d9eaba27979fb310a7a8dfd44097c", size = 1367031 } 131 | wheels = [ 132 | { url = "https://files.pythonhosted.org/packages/a4/85/2f4365a0112e49ff646bc701b93e8c4eb314812d84149a43898ea88d377d/stripe-11.3.0-py2.py3-none-any.whl", hash = "sha256:9d2e86943e1e4f325835d3860c4f58aa98d49229c9caf67588f9f9b2451e8e56", size = 1617363 }, 133 | ] 134 | 135 | [[package]] 136 | name = "typing-extensions" 137 | version = "4.13.2" 138 | source = { registry = "https://pypi.org/simple" } 139 | sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967 } 140 | wheels = [ 141 | { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806 }, 142 | ] 143 | 144 | [[package]] 145 | name = "urllib3" 146 | version = "2.4.0" 147 | source = { registry = "https://pypi.org/simple" } 148 | sdist = { url = "https://files.pythonhosted.org/packages/8a/78/16493d9c386d8e60e442a35feac5e00f0913c0f4b7c217c11e8ec2ff53e0/urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466", size = 390672 } 149 | wheels = [ 150 | { url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680 }, 151 | ] 152 | -------------------------------------------------------------------------------- /eventyay_stripe/static/plugins/stripe/eventyay-stripe.js: -------------------------------------------------------------------------------- 1 | /*global $, stripe_pubkey, stripe_loadingmessage, gettext */ 2 | 'use strict'; 3 | 4 | var stripeObj = { 5 | stripe: null, 6 | elements: null, 7 | card: null, 8 | paymentRequest: null, 9 | paymentRequestButton: null, 10 | 11 | 'cc_request': function () { 12 | waitingDialog.show(gettext("Contacting Stripe …")); 13 | $(".stripe-errors").hide(); 14 | 15 | // ToDo: 'card' --> proper type of payment method 16 | stripeObj.stripe.createPaymentMethod('card', stripeObj.card).then(function (result) { 17 | waitingDialog.hide(); 18 | if (result.error) { 19 | $(".stripe-errors").stop().hide().removeClass("sr-only"); 20 | $(".stripe-errors").html("
    " + result.error.message + "
    "); 21 | $(".stripe-errors").slideDown(); 22 | } else { 23 | let $form = $("#stripe_payment_method_id").closest("form"); 24 | // Insert the token into the form so it gets submitted to the server 25 | $("#stripe_payment_method_id").val(result.paymentMethod.id); 26 | $("#stripe_card_brand").val(result.paymentMethod.card.brand); 27 | $("#stripe_card_last4").val(result.paymentMethod.card.last4); 28 | // and submit 29 | $form.get(0).submit(); 30 | } 31 | }); 32 | }, 33 | 'pm_request': function (method, element, kwargs = {}) { 34 | waitingDialog.show(gettext("Contacting Stripe …")); 35 | $(".stripe-errors").hide(); 36 | 37 | stripeObj.stripe.createPaymentMethod(method, element, kwargs).then(function (result) { 38 | waitingDialog.hide(); 39 | if (result.error) { 40 | $(".stripe-errors").stop().hide().removeClass("sr-only"); 41 | $(".stripe-errors").html("
    " + result.error.message + "
    "); 42 | $(".stripe-errors").slideDown(); 43 | } else { 44 | var $form = $("#stripe_" + method + "_payment_method_id").closest("form"); 45 | // Insert the token into the form so it gets submitted to the server 46 | $("#stripe_" + method + "_payment_method_id").val(result.paymentMethod.id); 47 | if (method === 'card') { 48 | $("#stripe_card_brand").val(result.paymentMethod.card.brand); 49 | $("#stripe_card_last4").val(result.paymentMethod.card.last4); 50 | } 51 | if (method === 'sepa_debit') { 52 | $("#stripe_sepa_debit_last4").val(result.paymentMethod.sepa_debit.last4); 53 | } 54 | // and submit 55 | $form.get(0).submit(); 56 | } 57 | }).catch((e) => { 58 | waitingDialog.hide(); 59 | $(".stripe-errors").stop().hide().removeClass("sr-only"); 60 | $(".stripe-errors").html("
    Technical error, please contact support: " + e + "
    "); 61 | $(".stripe-errors").slideDown(); 62 | }); 63 | }, 64 | 'load': function () { 65 | if (stripeObj.stripe !== null) { 66 | return; 67 | } 68 | $('.stripe-container').closest("form").find(".checkout-button-row .btn-primary").prop("disabled", true); 69 | $.ajax( 70 | { 71 | url: 'https://js.stripe.com/v3/', 72 | dataType: 'script', 73 | success: function () { 74 | if ($.trim($("#stripe_connectedAccountId").html())) { 75 | stripeObj.stripe = Stripe($.trim($("#stripe_pubkey").html()), { 76 | stripeAccount: $.trim($("#stripe_connectedAccountId").html()), 77 | locale: $.trim($("body").attr("data-locale")) 78 | }); 79 | } else { 80 | stripeObj.stripe = Stripe($.trim($("#stripe_pubkey").html()), { 81 | locale: $.trim($("body").attr("data-locale")) 82 | }); 83 | } 84 | stripeObj.elements = stripeObj.stripe.elements(); 85 | if ($.trim($("#stripe_merchantcountry").html()) !== "") { 86 | try { 87 | stripeObj.paymentRequest = stripeObj.stripe.paymentRequest({ 88 | country: $("#stripe_merchantcountry").html(), 89 | currency: $("#stripe_currency").val().toLowerCase(), 90 | total: { 91 | label: gettext('Total'), 92 | amount: parseInt($("#stripe_total").val()) 93 | }, 94 | displayItems: [], 95 | requestPayerName: false, 96 | requestPayerEmail: false, 97 | requestPayerPhone: false, 98 | requestShipping: false, 99 | }); 100 | 101 | stripeObj.paymentRequest.on('paymentmethod', function (ev) { 102 | ev.complete('success'); 103 | 104 | let $form = $("#stripe_payment_method_id").closest("form"); 105 | // Insert the token into the form so it gets submitted to the server 106 | $("#stripe_payment_method_id").val(ev.paymentMethod.id); 107 | $("#stripe_card_brand").val(ev.paymentMethod.card.brand); 108 | $("#stripe_card_last4").val(ev.paymentMethod.card.last4); 109 | // and submit 110 | $form.get(0).submit(); 111 | }); 112 | } catch (e) { 113 | stripeObj.paymentRequest = null; 114 | } 115 | } else { 116 | stripeObj.paymentRequest = null; 117 | } 118 | if ($("#stripe-card").length) { 119 | stripeObj.card = stripeObj.elements.create('card', { 120 | 'style': { 121 | 'base': { 122 | 'fontFamily': '"Open Sans","OpenSans","Helvetica Neue",Helvetica,Arial,sans-serif', 123 | 'fontSize': '14px', 124 | 'color': '#555555', 125 | 'lineHeight': '1.42857', 126 | 'border': '1px solid #ccc', 127 | '::placeholder': { 128 | color: 'rgba(0,0,0,0.4)', 129 | }, 130 | }, 131 | 'invalid': { 132 | 'color': 'red', 133 | }, 134 | }, 135 | classes: { 136 | focus: 'is-focused', 137 | invalid: 'has-error', 138 | } 139 | }); 140 | stripeObj.card.mount("#stripe-card"); 141 | } 142 | stripeObj.card.on('ready', function () { 143 | $('.stripe-container').closest("form").find(".checkout-button-row .btn-primary").prop("disabled", false); 144 | }); 145 | if ($("#stripe-payment-request-button").length && stripeObj.paymentRequest != null) { 146 | stripeObj.paymentRequestButton = stripeObj.elements.create('paymentRequestButton', { 147 | paymentRequest: stripeObj.paymentRequest, 148 | }); 149 | 150 | stripeObj.paymentRequest.canMakePayment().then(function(result) { 151 | if (result) { 152 | stripeObj.paymentRequestButton.mount('#stripe-payment-request-button'); 153 | $('#stripe-elements .stripe-or').removeClass("hidden"); 154 | $('#stripe-payment-request-button').parent().removeClass("hidden"); 155 | } else { 156 | $('#stripe-payment-request-button').hide(); 157 | document.getElementById('stripe-payment-request-button').style.display = 'none'; 158 | } 159 | }) 160 | }; 161 | if ($("#stripe-sepa").length) { 162 | stripeObj.sepa = stripeObj.elements.create('iban', { 163 | 'style': { 164 | 'base': { 165 | 'fontFamily': '"Open Sans","OpenSans","Helvetica Neue",Helvetica,Arial,sans-serif', 166 | 'fontSize': '14px', 167 | 'color': '#555555', 168 | 'lineHeight': '1.42857', 169 | 'border': '1px solid #ccc', 170 | '::placeholder': { 171 | color: 'rgba(0,0,0,0.4)', 172 | }, 173 | }, 174 | 'invalid': { 175 | 'color': 'red', 176 | }, 177 | }, 178 | supportedCountries: ['SEPA'], 179 | classes: { 180 | focus: 'is-focused', 181 | invalid: 'has-error', 182 | } 183 | }); 184 | stripeObj.sepa.on('change', function (event) { 185 | // List of IBAN-countries, that require the country as well as line1-property according to 186 | // https://stripe.com/docs/payments/sepa-debit/accept-a-payment?platform=web&ui=element#web-submit-payment 187 | if (['AD', 'PF', 'TF', 'GI', 'GB', 'GG', 'VA', 'IM', 'JE', 'MC', 'NC', 'BL', 'PM', 'SM', 'CH', 'WF'].indexOf(event.country) > 0) { 188 | $("#stripe_sepa_debit_country").prop('checked', true); 189 | $("#stripe_sepa_debit_country").change(); 190 | } else { 191 | $("#stripe_sepa_debit_country").prop('checked', false); 192 | $("#stripe_sepa_debit_country").change(); 193 | } 194 | if (event.bankName) { 195 | $("#stripe_sepa_debit_bank").val(event.bankName); 196 | } 197 | }); 198 | stripeObj.sepa.mount("#stripe-sepa"); 199 | stripeObj.sepa.on('ready', function () { 200 | $('.stripe-container').closest("form").find(".checkout-button-row .btn-primary").prop("disabled", false); 201 | }); 202 | } 203 | if ($("#stripe-affirm").length) { 204 | stripeObj.affirm = stripeObj.elements.create('affirmMessage', { 205 | 'amount': parseInt($("#stripe_affirm_total").val()), 206 | 'currency': $("#stripe_affirm_currency").val(), 207 | }); 208 | 209 | stripeObj.affirm.mount('#stripe-affirm'); 210 | } 211 | if ($("#stripe-klarna").length) { 212 | try { 213 | stripeObj.klarna = stripeObj.elements.create('paymentMethodMessaging', { 214 | 'amount': parseInt($("#stripe_klarna_total").val()), 215 | 'currency': $("#stripe_klarna_currency").val(), 216 | 'countryCode': $("#stripe_klarna_country").val(), 217 | 'paymentMethodTypes': ['klarna'], 218 | }); 219 | 220 | stripeObj.klarna.mount('#stripe-klarna'); 221 | } catch (e) { 222 | console.error(e); 223 | $("#stripe-klarna").html("
    Technical error, please contact support: " + e + "
    "); 224 | } 225 | } 226 | } 227 | } 228 | ); 229 | }, 230 | 231 | 'confirmCard': function (payment_intent_client_secret) { 232 | $.ajax({ 233 | url: 'https://js.stripe.com/v3/', 234 | dataType: 'script', 235 | success: function () { 236 | if ($.trim($("#stripe_connectedAccountId").html())) { 237 | stripeObj.stripe = Stripe($.trim($("#stripe_pubkey").html()), { 238 | stripeAccount: $.trim($("#stripe_connectedAccountId").html()), 239 | locale: $.trim($("body").attr("data-locale")) 240 | }); 241 | } else { 242 | stripeObj.stripe = Stripe($.trim($("#stripe_pubkey").html()), { 243 | locale: $.trim($("body").attr("data-locale")) 244 | }); 245 | } 246 | stripeObj.stripe.confirmCard( 247 | payment_intent_client_secret 248 | ).then(function (result) { 249 | waitingDialog.show(gettext("Confirming your payment …")); 250 | location.reload(); 251 | }); 252 | } 253 | }); 254 | }, 255 | 'confirmCardiFrame': function (payment_intent_next_action_redirect_url) { 256 | waitingDialog.show(gettext("Contacting your bank …")); 257 | if (!isLiveMode()) { 258 | let iframe = document.createElement('iframe'); 259 | iframe.src = payment_intent_next_action_redirect_url; 260 | iframe.className = 'embed-responsive-item'; 261 | $('#scacontainer').append(iframe); 262 | $('#scacontainer iframe').load(function () { 263 | waitingDialog.hide(); 264 | }); 265 | } else { 266 | // Redirect in live mode 267 | window.location.href = payment_intent_next_action_redirect_url; 268 | } 269 | }, 270 | 'redirectToPayment': function (payment_intent_next_action_redirect_url) { 271 | waitingDialog.show(gettext("Contacting your bank …")); 272 | 273 | let payment_intent_redirect_action_handling = $.trim($("#stripe_payment_intent_redirect_action_handling").html()); 274 | if (payment_intent_redirect_action_handling === 'iframe' && !isLiveMode()) { 275 | let iframe = document.createElement('iframe'); 276 | iframe.src = payment_intent_next_action_redirect_url; 277 | iframe.className = 'embed-responsive-item'; 278 | $('#scacontainer').append(iframe); 279 | $('#scacontainer iframe').on("load", function () { 280 | waitingDialog.hide(); 281 | }); 282 | } else if (payment_intent_redirect_action_handling === 'redirect') { 283 | window.location.href = payment_intent_next_action_redirect_url; 284 | } 285 | }, 286 | 'redirectWechatPay': function (payment_intent_client_secret) { 287 | stripeObj.loadObject(function () { 288 | stripeObj.stripe.confirmWechatPayPayment( 289 | payment_intent_client_secret, 290 | { 291 | payment_method_options: { 292 | wechat_pay: { 293 | client: 'web', 294 | }, 295 | }, 296 | } 297 | ).then(function (result) { 298 | if (result.error) { 299 | waitingDialog.hide(); 300 | $(".stripe-errors").stop().hide().removeClass("sr-only"); 301 | $(".stripe-errors").html("
    Technical error, please contact support: " + result.error.message + "
    "); 302 | $(".stripe-errors").slideDown(); 303 | } else { 304 | waitingDialog.show(gettext("Confirming your payment …")); 305 | location.reload(); 306 | } 307 | }); 308 | }); 309 | }, 310 | 'redirectAlipay': function (payment_intent_client_secret) { 311 | stripeObj.loadObject(function () { 312 | stripeObj.stripe.confirmAlipayPayment( 313 | payment_intent_client_secret, 314 | { 315 | return_url: window.location.href 316 | } 317 | ).then(function (result) { 318 | if (result.error) { 319 | waitingDialog.hide(); 320 | $(".stripe-errors").stop().hide().removeClass("sr-only"); 321 | $(".stripe-errors").html("
    Technical error, please contact support: " + result.error.message + "
    "); 322 | $(".stripe-errors").slideDown(); 323 | } else { 324 | waitingDialog.show(gettext("Confirming your payment …")); 325 | } 326 | }); 327 | }); 328 | } 329 | }; 330 | $(function () { 331 | if ($("#stripe_payment_intent_SCA_status").length) { 332 | let payment_intent_redirect_action_handling = $.trim($("#stripe_payment_intent_redirect_action_handling").html()); 333 | let stt = $.trim($("#order_status").html()); 334 | let url = $.trim($("#order_url").html()) 335 | // show message 336 | if (payment_intent_redirect_action_handling === 'iframe') { 337 | window.parent.postMessage('3DS-authentication-complete.' + stt, '*'); 338 | return; 339 | } else if (payment_intent_redirect_action_handling === 'redirect') { 340 | waitingDialog.show(gettext("Confirming your payment …")); 341 | if (stt === 'p') { 342 | window.location.href = url + '?paid=yes'; 343 | } else { 344 | window.location.href = url; 345 | } 346 | } 347 | // redirect to payment url: ideal, bancontact, eps, przelewy24 348 | } else if ($("#stripe_payment_intent_next_action_redirect_url").length) { 349 | let payment_intent_next_action_redirect_url = $.trim($("#stripe_payment_intent_next_action_redirect_url").html()); 350 | stripeObj.redirectToPayment(payment_intent_next_action_redirect_url); 351 | // redirect to webchat pay 352 | } else if ($.trim($("#stripe_payment_intent_action_type").html()) === "wechat_pay_display_qr_code") { 353 | let payment_intent_client_secret = $.trim($("#stripe_payment_intent_client_secret").html()); 354 | stripeObj.redirectWechatPay(payment_intent_client_secret); 355 | // redirect to alipay 356 | } else if ($.trim($("#stripe_payment_intent_action_type").html()) === "alipay_handle_redirect") { 357 | let payment_intent_client_secret = $.trim($("#stripe_payment_intent_client_secret").html()); 358 | stripeObj.redirectAlipay(payment_intent_client_secret); 359 | // card payment 360 | } else if ($("#stripe_payment_intent_client_secret").length) { 361 | let payment_intent_client_secret = $.trim($("#stripe_payment_intent_client_secret").html()); 362 | stripeObj.confirmCard(payment_intent_client_secret); 363 | } 364 | 365 | $(window).on("message onmessage", function(e) { 366 | if (typeof e.originalEvent.data === "string" && e.originalEvent.data.startsWith('3DS-authentication-complete.')) { 367 | waitingDialog.show(gettext("Confirming your payment …")); 368 | $('#scacontainer').hide(); 369 | $('#continuebutton').removeClass('hidden'); 370 | 371 | if (e.originalEvent.data.split('.')[1] == 'p') { 372 | window.location.href = $('#continuebutton').attr('href') + '?paid=yes'; 373 | } else { 374 | window.location.href = $('#continuebutton').attr('href'); 375 | } 376 | } 377 | }); 378 | 379 | if (!$(".stripe-container").length) 380 | return; 381 | if ( 382 | $("input[name=payment][value=stripe]").is(':checked') 383 | || $("input[name=payment][value=stripe_sepa_debit]").is(':checked') 384 | || $("input[name=payment][value=stripe_affirm]").is(':checked') 385 | || $("input[name=payment][value=stripe_klarna]").is(':checked') 386 | || $(".payment-redo-form").length) { 387 | stripeObj.load(); 388 | } else { 389 | $("input[name=payment]").change(function () { 390 | if (['stripe', 'stripe_sepa_debit', 'stripe_affirm', 'stripe_klarna'].indexOf($(this).val()) > -1) { 391 | stripeObj.load(); 392 | } 393 | }) 394 | } 395 | 396 | $("#stripe_other_card").click( 397 | function (e) { 398 | $("#stripe_payment_method_id").val(""); 399 | $("#stripe-current-card").slideUp(); 400 | $("#stripe-elements").slideDown(); 401 | 402 | e.preventDefault(); 403 | return false; 404 | } 405 | ); 406 | 407 | if ($("#stripe-current-card").length) { 408 | $("#stripe-elements").hide(); 409 | } 410 | 411 | $("#stripe_other_account").click( 412 | function (e) { 413 | $("#stripe_sepa_debit_payment_method_id").val(""); 414 | $("#stripe-current-account").slideUp(); 415 | // We're using a css-selector here instead of the id-selector, 416 | // as we're hiding Stripe Elements *and* Django form fields 417 | $('.stripe-sepa_debit-form').slideDown(); 418 | 419 | e.preventDefault(); 420 | return false; 421 | } 422 | ); 423 | 424 | if ($("#stripe-current-account").length) { 425 | // We're using a css-selector here instead of the id-selector, 426 | // as we're hiding Stripe Elements *and* Django form fields 427 | $('.stripe-sepa_debit-form').hide(); 428 | } 429 | 430 | $('.stripe-container').closest("form").submit( 431 | function () { 432 | if ($("input[name=card_new]").length && !$("input[name=card_new]").prop('checked')) { 433 | return null; 434 | } 435 | if (($("input[name=payment][value=stripe]").prop('checked') || $("input[name=payment][type=radio]").length === 0) 436 | && $("#stripe_payment_method_id").val() == "") { 437 | stripeObj.cc_request(); 438 | return false; 439 | } 440 | 441 | if (($("input[name=payment][value=stripe_sepa_debit]").prop('checked')) && $("#stripe_sepa_debit_payment_method_id").val() == "") { 442 | stripeObj.pm_request('sepa_debit', stripeObj.sepa, { 443 | billing_details: { 444 | name: $("#id_payment_stripe_sepa_debit-accountname").val(), 445 | email: $("#stripe_sepa_debit_email").val(), 446 | address: { 447 | line1: $("#id_payment_stripe_sepa_debit-line1").val(), 448 | postal_code: $("#id_payment_stripe_sepa_debit-postal_code").val(), 449 | city: $("#id_payment_stripe_sepa_debit-city").val(), 450 | country: $("#id_payment_stripe_sepa_debit-country").val(), 451 | } 452 | } 453 | }); 454 | return false; 455 | } 456 | } 457 | ); 458 | }); 459 | function isLiveMode() { 460 | return window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1'; 461 | } 462 | -------------------------------------------------------------------------------- /eventyay_stripe/views.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import json 3 | import logging 4 | import urllib.parse 5 | from http import HTTPStatus 6 | 7 | import stripe 8 | from django.contrib import messages 9 | from django.core import signing 10 | from django.db import transaction 11 | from django.http import Http404, HttpResponse, HttpResponseBadRequest 12 | from django.shortcuts import get_object_or_404, redirect, render 13 | from django.urls import reverse 14 | from django.utils.decorators import method_decorator 15 | from django.utils.functional import cached_property 16 | from django.utils.translation import gettext_lazy as _ 17 | from django.views import View 18 | from django.views.decorators.clickjacking import xframe_options_exempt 19 | from django.views.decorators.csrf import csrf_exempt 20 | from django.views.decorators.http import require_POST 21 | from django.views.generic import FormView 22 | from django_scopes import scopes_disabled 23 | 24 | from pretix.base.models import Event, Order, OrderPayment, Organizer, Quota 25 | from pretix.base.payment import PaymentException 26 | from pretix.base.services.locking import LockTimeoutException 27 | from pretix.base.settings import GlobalSettingsObject 28 | from pretix.control.permissions import ( 29 | AdministratorPermissionRequiredMixin, event_permission_required, 30 | ) 31 | from pretix.control.views.event import DecoupleMixin 32 | from pretix.control.views.organizer import OrganizerDetailViewMixin 33 | from pretix.helpers import OF_SELF 34 | from pretix.helpers.http import redirect_to_url 35 | from pretix.helpers.stripe_utils import ( 36 | get_stripe_secret_key, get_stripe_webhook_secret_key, 37 | ) 38 | from pretix.multidomain.urlreverse import build_absolute_uri, eventreverse 39 | 40 | from .forms import OrganizerStripeSettingsForm 41 | from .models import ReferencedStripeObject 42 | from .payment import StripeCreditCard, StripeSettingsHolder 43 | from .tasks import get_domain_for_event, stripe_verify_domain 44 | 45 | logger = logging.getLogger(__name__) 46 | 47 | 48 | @xframe_options_exempt 49 | def redirect_view(request, *args, **kwargs): 50 | try: 51 | data = signing.loads(request.GET.get('data', ''), salt='safe-redirect') 52 | except signing.BadSignature: 53 | return HttpResponseBadRequest('Invalid parameter') 54 | 55 | if 'go' in request.GET: 56 | if 'session' in data: 57 | for k, v in data['session'].items(): 58 | request.session[k] = v 59 | return redirect(data['url']) 60 | else: 61 | params = request.GET.copy() 62 | params['go'] = '1' 63 | r = render(request, 'plugins/stripe/redirect.html', { 64 | 'url': ( 65 | build_absolute_uri(request.event, 'plugins:eventyay_stripe:redirect') + '?' 66 | + urllib.parse.urlencode(params) 67 | ), 68 | }) 69 | r._csp_ignore = True 70 | return r 71 | 72 | 73 | @scopes_disabled() 74 | def oauth_return(request, *args, **kwargs): 75 | if 'payment_stripe_oauth_event' not in request.session: 76 | messages.error(request, _('An error occurred during connecting with Stripe, please try again.')) 77 | return redirect('control:index') 78 | 79 | event = get_object_or_404(Event, pk=request.session['payment_stripe_oauth_event']) 80 | 81 | if request.GET.get('state') != request.session['payment_stripe_oauth_token']: 82 | messages.error(request, _('An error occurred during connecting with Stripe, please try again.')) 83 | return redirect_to_url(reverse('control:event.settings.payment.provider', kwargs={ 84 | 'organizer': event.organizer.slug, 85 | 'event': event.slug, 86 | 'provider': 'stripe_settings' 87 | })) 88 | 89 | gs = GlobalSettingsObject() 90 | testdata = {} 91 | 92 | try: 93 | stripe.api_key = ( 94 | gs.settings.payment_stripe_connect_secret_key 95 | or gs.settings.payment_stripe_connect_test_secret_key 96 | ) 97 | code = request.GET.get('code') 98 | data = stripe.OAuth.token(grant_type="authorization_code", code=code) 99 | if 'error' not in data: 100 | account = stripe.Account.retrieve(data['stripe_user_id'], api_key=stripe.api_key) 101 | except stripe.error.StripeError as e: 102 | logger.exception('Failed to obtain OAuth token %s', e) 103 | messages.error(request, _('An error occurred during connecting with Stripe, please try again.')) 104 | else: 105 | if 'error' not in data and data['livemode']: 106 | try: 107 | testdata = stripe.OAuth.token( 108 | grant_type='refresh_token', 109 | refresh_token=data['refresh_token'], 110 | client_secret=gs.settings.payment_stripe_connect_test_secret_key 111 | ) 112 | except stripe.error.StripeError as e: 113 | logger.exception('Failed to obtain OAuth token %s', e) 114 | messages.error(request, _('An error occurred during connecting with Stripe, please try again.')) 115 | return redirect(reverse('control:event.settings.payment.provider', kwargs={ 116 | 'organizer': event.organizer.slug, 117 | 'event': event.slug, 118 | 'provider': 'stripe_settings' 119 | })) 120 | 121 | if 'error' in data: 122 | messages.error( 123 | request, 124 | _('Stripe returned an error: {}').format(data['error_description']) 125 | ) 126 | elif data['livemode'] and 'error' in testdata: 127 | messages.error( 128 | request, 129 | _('Stripe returned an error: {}').format(testdata['error_description']) 130 | ) 131 | else: 132 | messages.success( 133 | request, 134 | _('Your Stripe account is now connected to eventyay. ' 135 | 'You can change the settings in detail below.') 136 | ) 137 | event.settings.payment_stripe_publishable_key = data['stripe_publishable_key'] 138 | event.settings.payment_stripe_connect_refresh_token = data['refresh_token'] 139 | event.settings.payment_stripe_connect_user_id = data['stripe_user_id'] 140 | event.settings.payment_stripe_merchant_country = account.get('country') 141 | if ( 142 | account.get('business_name') 143 | or account.get('name') 144 | or account.get('email') 145 | or account.get('settings', {}).get('dashboard', {}).get('display_name') 146 | ): 147 | event.settings.payment_stripe_connect_user_name = ( 148 | account.get('business_profile', {}).get('name') 149 | or account.get('business_name') 150 | or account.get('display_name') 151 | or account.get('email') 152 | or account.get('settings', {}).get('dashboard', {}).get('display_name') 153 | ) 154 | 155 | if data['livemode']: 156 | event.settings.payment_stripe_publishable_test_key = testdata['stripe_publishable_key'] 157 | else: 158 | event.settings.payment_stripe_publishable_test_key = event.settings.payment_stripe_publishable_key 159 | 160 | if request.session.get('payment_stripe_oauth_enable', False): 161 | event.settings.payment_stripe__enabled = True 162 | del request.session['payment_stripe_oauth_enable'] 163 | 164 | stripe_verify_domain.apply_async(args=(event.pk, get_domain_for_event(event))) 165 | 166 | return redirect(reverse('control:event.settings.payment.provider', kwargs={ 167 | 'organizer': event.organizer.slug, 168 | 'event': event.slug, 169 | 'provider': 'stripe_settings' 170 | })) 171 | 172 | 173 | @csrf_exempt 174 | @require_POST 175 | @scopes_disabled() 176 | def webhook(request, *args, **kwargs): 177 | # Refer: https://stripe.com/docs/webhooks 178 | try: 179 | payload = request.body 180 | if not payload: 181 | logger.exception('Empty payload on webhook') 182 | return HttpResponse("Empty payload", status=HTTPStatus.BAD_REQUEST) 183 | event_json = json.loads(payload.decode('utf-8')) 184 | sig_header = request.META.get("HTTP_STRIPE_SIGNATURE") 185 | stripe.api_key = get_stripe_secret_key() 186 | webhook_secret_key = get_stripe_webhook_secret_key() 187 | # Verify the event with the Stripe library 188 | stripe.Webhook.construct_event( 189 | payload, sig_header, webhook_secret_key 190 | ) 191 | except AttributeError as e: 192 | logger.exception('Attribute Error on webhook: %s', e) 193 | return HttpResponse("Cannot verify Stripe signature", status=HTTPStatus.BAD_REQUEST) 194 | except (json.decoder.JSONDecodeError, ValueError) as e: 195 | # Invalid payload 196 | logger.exception('Invalid payload on webhook: %s', e) 197 | return HttpResponse("Invalid payload", status=HTTPStatus.BAD_REQUEST) 198 | except stripe.error.SignatureVerificationError as e: 199 | # Invalid signature 200 | logger.exception('Stripe error on webhook: %s', e) 201 | return HttpResponse("Invalid Stripe signature", status=HTTPStatus.BAD_REQUEST) 202 | 203 | obj = event_json['data']['object'] 204 | if (obj_type := obj['object']) == "charge": 205 | func = charge_webhook 206 | objid = obj['id'] 207 | lookup_ids = [objid, (obj.get('source') or {}).get('id')] 208 | elif obj_type == "dispute": 209 | func = charge_webhook 210 | objid = obj['charge'] 211 | lookup_ids = [objid] 212 | elif obj_type == "source": 213 | func = source_webhook 214 | objid = obj['id'] 215 | lookup_ids = [objid] 216 | elif obj_type == "payment_intent": 217 | func = paymentintent_webhook 218 | objid = obj['id'] 219 | lookup_ids = [objid] 220 | else: 221 | return HttpResponse("Not interested in this data type", status=HTTPStatus.OK) 222 | 223 | rso = ReferencedStripeObject.objects.select_related('order', 'order__event').filter( 224 | reference__in=[lid for lid in lookup_ids if lid] 225 | ).first() 226 | if rso: 227 | return func(rso.order.event, event_json, objid, rso) 228 | else: 229 | if obj_type == "charge" and 'payment_intent' in obj: 230 | # If we receive a charge webhook *before* the payment intent webhook, we don't know the charge ID yet 231 | # and can't match it -- but we know the payment intent ID! 232 | try: 233 | rso = ReferencedStripeObject.objects.select_related('order', 'order__event').get( 234 | reference=event_json['data']['object']['payment_intent'] 235 | ) 236 | return func(rso.order.event, event_json, objid, rso) 237 | except ReferencedStripeObject.DoesNotExist: 238 | return HttpResponse("Unable to detect event", status=HTTPStatus.OK) 239 | elif hasattr(request, 'event') and func != paymentintent_webhook: 240 | # This is a legacy integration from back when didn't have ReferencedStripeObject. This can't happen for 241 | # payment intents or charges connected with payment intents since they didn't exist back then. Our best 242 | # hope is to go for request.event and see if we can find the order ID. 243 | return func(request.event, event_json, objid, None) 244 | 245 | # Okay, this is probably not an event that concerns us, maybe other applications talk to the same stripe 246 | # account 247 | return HttpResponse("Unable to detect event", status=HTTPStatus.OK) 248 | 249 | 250 | SOURCE_TYPES = { 251 | 'sofort': 'stripe_sofort', 252 | 'three_d_secure': 'stripe', 253 | 'card': 'stripe', 254 | 'sepa_debit': 'stripe_sepa_debit', 255 | 'ideal': 'stripe_ideal', 256 | 'alipay': 'stripe_alipay', 257 | 'bancontact': 'stripe_bancontact', 258 | } 259 | 260 | 261 | def charge_webhook(event, event_json, charge_id, rso): 262 | prov = StripeCreditCard(event) 263 | prov._init_api() 264 | 265 | try: 266 | charge = stripe.Charge.retrieve( 267 | charge_id, 268 | expand=['dispute', 'refunds', 'payment_intent', 'payment_intent.latest_charge'], 269 | **prov.api_config 270 | ) 271 | except stripe.error.StripeError: 272 | logger.exception('Stripe error on webhook. Event data: %s', str(event_json)) 273 | return HttpResponse('Charge not found', status=HTTPStatus.INTERNAL_SERVER_ERROR) 274 | 275 | metadata = charge['metadata'] 276 | if 'event' not in metadata: 277 | return HttpResponse('Event not given in charge metadata', status=HTTPStatus.OK) 278 | 279 | if int(metadata['event']) != event.pk: 280 | return HttpResponse('Not interested in this event', status=HTTPStatus.OK) 281 | 282 | if rso and rso.payment: 283 | order = rso.payment.order 284 | payment = rso.payment 285 | elif rso: 286 | order = rso.order 287 | payment = None 288 | else: 289 | try: 290 | order = event.orders.get(id=metadata['order']) 291 | except Order.DoesNotExist: 292 | return HttpResponse('Order not found', status=HTTPStatus.OK) 293 | payment = None 294 | 295 | with transaction.atomic(): 296 | if payment: 297 | payment = OrderPayment.objects.select_for_update(of=OF_SELF).get(pk=payment.pk) 298 | else: 299 | payment = order.payments.filter( 300 | info__icontains=charge['id'], 301 | provider__startswith='stripe', 302 | amount=prov._amount_to_decimal(charge['amount']), 303 | ).select_for_update(of=OF_SELF).last() 304 | if not payment: 305 | payment = order.payments.create( 306 | state=OrderPayment.PAYMENT_STATE_CREATED, 307 | provider=(SOURCE_TYPES.get( 308 | charge['source'].get('type', charge['source'].get('object', 'card')), 'stripe' 309 | )), 310 | amount=prov._amount_to_decimal(charge['amount']), 311 | info=str(charge), 312 | ) 313 | 314 | if payment.provider != prov.identifier: 315 | prov = payment.payment_provider 316 | prov._init_api() 317 | 318 | order.log_action('pretix.plugins.stripe.event', data=event_json) 319 | 320 | is_refund = charge['amount_refunded'] or charge['refunds']['total_count'] or charge['dispute'] 321 | if is_refund: 322 | known_refunds = [r.info_data.get('id') for r in payment.refunds.all()] 323 | migrated_refund_amounts = [r.amount for r in payment.refunds.all() if not r.info_data.get('id')] 324 | for r in charge['refunds']['data']: 325 | a = prov._amount_to_decimal(r['amount']) 326 | if r['status'] in ('failed', 'canceled'): 327 | continue 328 | 329 | if a in migrated_refund_amounts: 330 | migrated_refund_amounts.remove(a) 331 | continue 332 | 333 | if r['id'] not in known_refunds: 334 | payment.create_external_refund( 335 | amount=a, 336 | info=str(r) 337 | ) 338 | if charge['dispute']: 339 | if charge['dispute']['status'] != 'won' and charge['dispute']['id'] not in known_refunds: 340 | a = prov._amount_to_decimal(charge['dispute']['amount']) 341 | if a in migrated_refund_amounts: 342 | migrated_refund_amounts.remove(a) 343 | else: 344 | payment.create_external_refund( 345 | amount=a, 346 | info=str(charge['dispute']) 347 | ) 348 | elif charge['status'] == 'succeeded' and payment.state in ( 349 | OrderPayment.PAYMENT_STATE_PENDING, 350 | OrderPayment.PAYMENT_STATE_CREATED, 351 | OrderPayment.PAYMENT_STATE_CANCELED, 352 | OrderPayment.PAYMENT_STATE_FAILED 353 | ): 354 | try: 355 | if getattr(charge, "payment_intent", None): 356 | payment.info = str(charge.payment_intent) 357 | payment.confirm() 358 | except LockTimeoutException: 359 | return HttpResponse("Lock timeout, please try again.", status=HTTPStatus.SERVICE_UNAVAILABLE) 360 | except Quota.QuotaExceededException: 361 | pass 362 | elif charge['status'] == 'failed' and payment.state in ( 363 | OrderPayment.PAYMENT_STATE_PENDING, 364 | OrderPayment.PAYMENT_STATE_CREATED 365 | ): 366 | payment.fail(info=str(charge)) 367 | 368 | return HttpResponse(status=HTTPStatus.OK) 369 | 370 | 371 | def source_webhook(event, event_json, source_id, rso): 372 | prov = StripeCreditCard(event) 373 | prov._init_api() 374 | try: 375 | src = stripe.Source.retrieve(source_id, **prov.api_config) 376 | except stripe.error.StripeError: 377 | logger.exception('Stripe error on webhook. Event data: %s' % str(event_json)) 378 | return HttpResponse('Charge not found', status=500) 379 | 380 | metadata = src['metadata'] 381 | if 'event' not in metadata: 382 | return HttpResponse('Event not given in charge metadata', status=HTTPStatus.OK) 383 | 384 | if int(metadata['event']) != event.pk: 385 | return HttpResponse('Not interested in this event', status=HTTPStatus.OK) 386 | 387 | with transaction.atomic(): 388 | if rso and rso.payment: 389 | order = rso.payment.order 390 | payment = rso.payment 391 | elif rso: 392 | order = rso.order 393 | payment = None 394 | else: 395 | try: 396 | order = event.orders.get(id=metadata['order']) 397 | except Order.DoesNotExist: 398 | return HttpResponse('Order not found', status=HTTPStatus.OK) 399 | payment = None 400 | 401 | if payment: 402 | payment = OrderPayment.objects.select_for_update(of=OF_SELF).get(pk=payment.pk) 403 | else: 404 | payment = order.payments.filter( 405 | info__icontains=src['id'], 406 | provider__startswith='stripe', 407 | amount=prov._amount_to_decimal(src['amount']) if src['amount'] is not None else order.total, 408 | ).select_for_update(of=OF_SELF).last() 409 | if not payment: 410 | payment = order.payments.create( 411 | state=OrderPayment.PAYMENT_STATE_CREATED, 412 | provider=SOURCE_TYPES.get(src['type'], 'stripe'), 413 | amount=prov._amount_to_decimal(src['amount']) if src['amount'] is not None else order.total, 414 | info=str(src), 415 | ) 416 | 417 | if payment.provider != prov.identifier: 418 | prov = payment.payment_provider 419 | prov._init_api() 420 | 421 | order.log_action('pretix.plugins.stripe.event', data=event_json) 422 | go = (event_json['type'] == 'source.chargeable' and 423 | payment.state in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED) and 424 | src.status == 'chargeable') 425 | if go: 426 | try: 427 | prov._charge_source(None, source_id, payment) 428 | except PaymentException: 429 | logger.exception('Webhook error') 430 | elif src.status == 'failed': 431 | payment.fail(info=str(src)) 432 | elif src.status == 'canceled' and payment.state in ( 433 | OrderPayment.PAYMENT_STATE_PENDING, 434 | OrderPayment.PAYMENT_STATE_CREATED 435 | ): 436 | payment.info = str(src) 437 | payment.state = OrderPayment.PAYMENT_STATE_CANCELED 438 | payment.save() 439 | 440 | return HttpResponse(status=HTTPStatus.OK) 441 | 442 | 443 | def paymentintent_webhook(event, event_json, paymentintent_id, rso): 444 | prov = StripeCreditCard(event) 445 | prov._init_api() 446 | 447 | try: 448 | paymentintent = stripe.PaymentIntent.retrieve(paymentintent_id, expand=["latest_charge"], **prov.api_config) 449 | except stripe.error.StripeError: 450 | logger.exception('Stripe error on webhook. Event data: %s' % str(event_json)) 451 | return HttpResponse('Charge not found', status=HTTPStatus.INTERNAL_SERVER_ERROR) 452 | 453 | if paymentintent.latest_charge: 454 | ReferencedStripeObject.objects.get_or_create( 455 | reference=paymentintent.latest_charge.id, 456 | defaults={'order': rso.payment.order, 'payment': rso.payment} 457 | ) 458 | 459 | if event_json["type"] == "payment_intent.payment_failed": 460 | rso.payment.fail(info=event_json) 461 | 462 | return HttpResponse(status=HTTPStatus.OK) 463 | 464 | 465 | @event_permission_required('can_change_event_settings') 466 | def oauth_disconnect(request, **kwargs): 467 | if request.method != "POST": 468 | return render(request, 'plugins/stripe/oauth_disconnect.html', {}) 469 | 470 | del request.event.settings.payment_stripe_publishable_key 471 | del request.event.settings.payment_stripe_publishable_test_key 472 | del request.event.settings.payment_stripe_connect_access_token 473 | del request.event.settings.payment_stripe_connect_refresh_token 474 | del request.event.settings.payment_stripe_connect_user_id 475 | del request.event.settings.payment_stripe_connect_user_name 476 | request.event.settings.payment_stripe__enabled = False 477 | messages.success(request, _('Your Stripe account has been disconnected.')) 478 | 479 | return redirect_to_url(reverse('control:event.settings.payment.provider', kwargs={ 480 | 'organizer': request.event.organizer.slug, 481 | 'event': request.event.slug, 482 | 'provider': 'stripe_settings' 483 | })) 484 | 485 | 486 | class StripeOrderView(View): 487 | def dispatch(self, request, *args, **kwargs): 488 | try: 489 | self.order = request.event.orders.get_with_secret_check( 490 | code=kwargs['order'], received_secret=kwargs['hash'].lower(), tag='plugins:eventyay_stripe' 491 | ) 492 | except Order.DoesNotExist as e: 493 | try: 494 | # try retrieving order with hash (old method) 495 | self.order = request.event.orders.get(code=kwargs['order']) 496 | if hashlib.sha1(self.order.secret.lower().encode()).hexdigest() != kwargs['hash'].lower(): 497 | raise Http404('Unknown order') from e 498 | except Order.DoesNotExist as exc: 499 | raise Http404('Unknown order') from exc 500 | 501 | self.payment = get_object_or_404(self.order.payments, pk=kwargs['payment'], provider__startswith='stripe') 502 | 503 | return super().dispatch(request, *args, **kwargs) 504 | 505 | @cached_property 506 | def pprov(self): 507 | # Return the payment provider object 508 | return self.request.event.get_payment_providers()[self.payment.provider] 509 | 510 | def _redirect_to_order(self): 511 | # Check if the session secret matches the order secret 512 | if ( 513 | self.request.session.get('payment_stripe_order_secret') != self.order.secret 514 | and not self.payment.provider.startswith('stripe') 515 | ): 516 | messages.error( 517 | self.request, _('Sorry, there was an error in the payment process. ' 518 | 'Please check the link in your emails to continue.') 519 | ) 520 | return redirect(eventreverse(self.request.event, 'presale:event.index')) 521 | 522 | # Redirect to the order page with payment status 523 | return redirect(eventreverse(self.request.event, 'presale:event.order', kwargs={ 524 | 'order': self.order.code, 525 | 'secret': self.order.secret 526 | }) + ('?paid=yes' if self.order.status == Order.STATUS_PAID else '')) 527 | 528 | 529 | @method_decorator(xframe_options_exempt, 'dispatch') 530 | class ReturnView(StripeOrderView, View): 531 | def get(self, request, *args, **kwargs): 532 | prov = self.pprov 533 | prov._init_api() 534 | try: 535 | src = stripe.Source.retrieve(request.GET.get('source'), **prov.api_config) 536 | except stripe.error.InvalidRequestError: 537 | logger.exception('Could not retrieve source') 538 | messages.error(self.request, _('Sorry, there was an error in the payment process. Please check the link ' 539 | 'in your emails to continue.')) 540 | return redirect_to_url(eventreverse(self.request.event, 'presale:event.index')) 541 | 542 | if src.client_secret != request.GET.get('client_secret'): 543 | messages.error(self.request, _('Sorry, there was an error in the payment process. Please check the link ' 544 | 'in your emails to continue.')) 545 | return redirect_to_url(eventreverse(self.request.event, 'presale:event.index')) 546 | 547 | with transaction.atomic(): 548 | self.order.refresh_from_db() 549 | self.payment = OrderPayment.objects.select_for_update(of=OF_SELF).get(pk=self.payment.pk) 550 | if self.payment.state == OrderPayment.PAYMENT_STATE_CONFIRMED: 551 | if 'payment_stripe_token' in request.session: 552 | del request.session['payment_stripe_token'] 553 | return self._redirect_to_order() 554 | 555 | if src.status == 'chargeable': 556 | try: 557 | prov._charge_source(request, src.id, self.payment) 558 | except PaymentException as e: 559 | messages.error(request, str(e)) 560 | return self._redirect_to_order() 561 | finally: 562 | if 'payment_stripe_token' in request.session: 563 | del request.session['payment_stripe_token'] 564 | elif src.status == 'consumed': 565 | # Webhook was faster, wow! ;) 566 | if 'payment_stripe_token' in request.session: 567 | del request.session['payment_stripe_token'] 568 | return self._redirect_to_order() 569 | elif src.status == 'pending': 570 | self.payment.state = OrderPayment.PAYMENT_STATE_PENDING 571 | self.payment.info = str(src) 572 | self.payment.save() 573 | else: # failed or canceled 574 | self.payment.fail(info=str(src)) 575 | messages.error(self.request, _('We had trouble authorizing your card payment. Please try again and ' 576 | 'get in touch with us if this problem persists.')) 577 | return self._redirect_to_order() 578 | 579 | 580 | @method_decorator(xframe_options_exempt, 'dispatch') 581 | class ScaView(StripeOrderView, View): 582 | def get(self, request, *args, **kwargs): 583 | prov = self.pprov 584 | prov._init_api() 585 | 586 | # Redirect if payment state is final 587 | if self.payment.state in (OrderPayment.PAYMENT_STATE_CONFIRMED, 588 | OrderPayment.PAYMENT_STATE_CANCELED, 589 | OrderPayment.PAYMENT_STATE_FAILED): 590 | return self._redirect_to_order() 591 | 592 | # Retrieve the PaymentIntent 593 | payment_info = json.loads(self.payment.info) 594 | intent = self._get_payment_intent(prov, payment_info) 595 | if not intent: 596 | messages.error(self.request, _('Sorry, there was an error in the payment process.')) 597 | return self._redirect_to_order() 598 | 599 | # Handle PaymentIntent next actions 600 | if self._needs_additional_action(intent): 601 | return self._handle_additional_action(request, prov, intent) 602 | else: 603 | return self._complete_payment(request, prov, intent) 604 | 605 | def _get_payment_intent(self, prov, payment_info): 606 | """Retrieve the PaymentIntent from Stripe.""" 607 | if 'id' in payment_info: 608 | try: 609 | return stripe.PaymentIntent.retrieve( 610 | payment_info['id'], 611 | expand=["latest_charge"], 612 | **prov.api_config 613 | ) 614 | except stripe.error.InvalidRequestError: 615 | logger.exception('Could not retrieve payment intent') 616 | return None 617 | 618 | def _needs_additional_action(self, intent): 619 | """Check if the PaymentIntent requires further action.""" 620 | return intent.status == 'requires_action' and intent.next_action.type in [ 621 | 'use_stripe_sdk', 622 | 'redirect_to_url', 623 | 'alipay_handle_redirect', 624 | 'wechat_pay_display_qr_code', 625 | 'swish_handle_redirect_or_display_qr_code' 626 | ] 627 | 628 | def _handle_additional_action(self, request, prov, intent): 629 | """Render the SCA template with appropriate context.""" 630 | ctx = { 631 | 'order': self.order, 632 | 'stripe_settings': StripeSettingsHolder(self.order.event).settings, 633 | 'payment_intent_action_type': intent.next_action.type, 634 | } 635 | 636 | if intent.next_action.type == 'redirect_to_url': 637 | ctx['payment_intent_next_action_redirect_url'] = intent.next_action.redirect_to_url['url'] 638 | ctx['payment_intent_redirect_action_handling'] = prov.redirect_action_handling 639 | elif intent.next_action.type in ('use_stripe_sdk', 'alipay_handle_redirect', 'wechat_pay_display_qr_code'): 640 | ctx['payment_intent_client_secret'] = intent.client_secret 641 | elif intent.next_action.type == 'multibanco_display_details': 642 | ctx['payment_intent_next_action_redirect_url'] = ( 643 | intent.next_action.multibanco_display_details['hosted_voucher_url']) 644 | ctx['payment_intent_redirect_action_handling'] = 'redirect' 645 | 646 | r = render(request, 'plugins/stripe/sca.html', ctx) 647 | r._csp_ignore = True 648 | return r 649 | 650 | def _complete_payment(self, request, prov, intent): 651 | try: 652 | prov._handle_intent_response(request, self.payment, intent) 653 | except PaymentException as e: 654 | messages.error(request, str(e)) 655 | 656 | return self._redirect_to_order() 657 | 658 | 659 | @method_decorator(xframe_options_exempt, 'dispatch') 660 | class ScaReturnView(StripeOrderView, View): 661 | def get(self, request, *args, **kwargs): 662 | prov = self.pprov 663 | 664 | try: 665 | prov._handle_intent_response(request, self.payment) 666 | except PaymentException as e: 667 | messages.error(request, str(e)) 668 | 669 | self.order.refresh_from_db() 670 | ctx = { 671 | 'order': self.order, 672 | 'payment_intent_redirect_action_handling': prov.redirect_action_handling, 673 | 'order_url': eventreverse(self.request.event, 'presale:event.order', kwargs={ 674 | 'order': self.order.code, 675 | 'secret': self.order.secret 676 | }), 677 | } 678 | 679 | return render(request, 'plugins/stripe/sca_return.html', ctx) 680 | 681 | 682 | class OrganizerSettingsFormView( 683 | DecoupleMixin, 684 | OrganizerDetailViewMixin, 685 | AdministratorPermissionRequiredMixin, 686 | FormView 687 | ): 688 | model = Organizer 689 | permission = 'can_change_organizer_settings' 690 | form_class = OrganizerStripeSettingsForm 691 | template_name = 'plugins/stripe/organizer_stripe.html' 692 | 693 | def get_success_url(self): 694 | return reverse('plugins:eventyay_stripe:settings.connect', kwargs={ 695 | 'organizer': self.request.organizer.slug, 696 | }) 697 | 698 | def get_form_kwargs(self): 699 | kwargs = super().get_form_kwargs() 700 | kwargs['obj'] = self.request.organizer 701 | return kwargs 702 | 703 | @transaction.atomic 704 | def post(self, request, *args, **kwargs): 705 | form = self.get_form() 706 | if form.is_valid(): 707 | form.save() 708 | if form.has_changed(): 709 | self.request.organizer.log_action( 710 | 'pretix.organizer.settings', user=self.request.user, data={ 711 | k: form.cleaned_data.get(k) for k in form.changed_data 712 | } 713 | ) 714 | messages.success(self.request, _('Your changes have been saved.')) 715 | return redirect_to_url(self.get_success_url()) 716 | else: 717 | messages.error(self.request, _('We could not save your changes. See below for details.')) 718 | return self.get(request) 719 | --------------------------------------------------------------------------------