diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py
index 5cbbf18242..ff0f86dbc7 100644
--- a/src/pretix/base/models/event.py
+++ b/src/pretix/base/models/event.py
@@ -1226,7 +1226,7 @@ class Event(EventMixin, LoggedModel):
self.set_active_plugins(plugins_active)
plugins_available = self.get_available_plugins()
- if hasattr(plugins_available[module].app, 'uninstalled'):
+ if module in plugins_available and hasattr(plugins_available[module].app, 'uninstalled'):
getattr(plugins_available[module].app, 'uninstalled')(self)
regenerate_css.apply_async(args=(self.pk,))
diff --git a/src/pretix/plugins/paypal/apps.py b/src/pretix/plugins/paypal/apps.py
index d9f2921f4e..2a07d46a8c 100644
--- a/src/pretix/plugins/paypal/apps.py
+++ b/src/pretix/plugins/paypal/apps.py
@@ -56,6 +56,9 @@ class PaypalApp(AppConfig):
def ready(self):
from . import signals # NOQA
+ def is_available(self, event):
+ return 'pretix.plugins.paypal' in event.plugins.split(',')
+
@cached_property
def compatibility_errors(self):
errs = []
diff --git a/src/pretix/plugins/paypal/migrations/0003_migrate_to_v2.py b/src/pretix/plugins/paypal/migrations/0003_migrate_to_v2.py
new file mode 100644
index 0000000000..50628084a1
--- /dev/null
+++ b/src/pretix/plugins/paypal/migrations/0003_migrate_to_v2.py
@@ -0,0 +1,52 @@
+# Generated by Django 3.2.13 on 2022-05-25 08:39
+
+from django.db import migrations
+from django.db.models import Q
+
+
+def migrate_to_v2(app, schema_editor):
+ GlobalSettingsObject_SettingsStore = app.get_model('pretixbase', 'GlobalSettingsObject_SettingsStore')
+ EventSettingsStore = app.get_model('pretixbase', 'Event_SettingsStore')
+ Event = app.get_model('pretixbase', 'Event')
+
+ # If there are no system-wide OAuth keys set, per-event API keys are used, and we can migrate all events
+ if not GlobalSettingsObject_SettingsStore.objects.filter(
+ Q(key__in=['payment_paypal_connect_client_id', 'payment_paypal_connect_secret_key'])
+ & (Q(value__isnull=True) | ~Q(value=""))
+ ).exists():
+ for ev in Event.objects.filter(plugins__contains='pretix.plugins.paypal'):
+ switch_paypal_version(ev)
+
+ # There are system-wide OAuth keys set - so we need to check each event individually
+ else:
+ # Only look at events that have the PayPal plugin enabled
+ for ev in Event.objects.filter(plugins__contains='pretix.plugins.paypal'):
+ # If the payment method is enabled, we don't do anything
+ if EventSettingsStore.objects.filter(object_id=ev.pk, key='payment_paypal__enabled', value="True").exists():
+ pass
+ # In all other cases, the payment method is either disabled or hasn't been set up - in this case we'll
+ # migrate the event to v2
+ else:
+ switch_paypal_version(ev)
+
+
+def switch_paypal_version(event):
+ plugins_active = event.plugins.split(',')
+
+ if 'pretix.plugins.paypal' in plugins_active:
+ plugins_active.remove('pretix.plugins.paypal')
+ plugins_active.append('pretix.plugins.paypal2')
+
+ event.plugins = ','.join(plugins_active)
+ event.save(update_fields=['plugins'])
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('paypal', '0002_referencedpaypalobject_payment'),
+ ]
+
+ operations = [
+ migrations.RunPython(migrate_to_v2, migrations.RunPython.noop)
+ ]
diff --git a/src/pretix/plugins/paypal/payment.py b/src/pretix/plugins/paypal/payment.py
index 3d9cf86a1d..5f02b5e147 100644
--- a/src/pretix/plugins/paypal/payment.py
+++ b/src/pretix/plugins/paypal/payment.py
@@ -57,7 +57,6 @@ from pretix.base.models import Event, Order, OrderPayment, OrderRefund, Quota
from pretix.base.payment import BasePaymentProvider, PaymentException
from pretix.base.services.mail import SendMailException
from pretix.base.settings import SettingsSandbox
-from pretix.helpers.urls import build_absolute_uri as build_global_uri
from pretix.multidomain.urlreverse import build_absolute_uri
from pretix.plugins.paypal.models import ReferencedPayPalObject
@@ -171,16 +170,10 @@ class Paypal(BasePaymentProvider):
if self.settings.connect_client_id and not self.settings.secret:
# Use PayPal connect
if not self.settings.connect_user_id:
- settings_content = (
- "
{}
"
- "{}"
- ).format(
- _('To accept payments via PayPal, you will need an account at PayPal. By clicking on the '
- 'following button, you can either create a new PayPal account connect pretix to an existing '
- 'one.'),
- self.get_connect_url(request),
- _('Connect with {icon} PayPal').format(icon='')
- )
+ # Migrate User to PayPal v2
+ self.event.disable_plugin("pretix.plugins.paypal")
+ self.event.enable_plugin("pretix.plugins.paypal2")
+ self.event.save()
else:
settings_content = (
""
@@ -192,29 +185,10 @@ class Paypal(BasePaymentProvider):
_('Disconnect from PayPal')
)
else:
- settings_content = "
%s %s
" % (
- _('Please configure a PayPal Webhook to the following endpoint in order to automatically cancel orders '
- 'when payments are refunded externally.'),
- build_global_uri('plugins:paypal:webhook')
- )
-
- if self.event.currency not in SUPPORTED_CURRENCIES:
- settings_content += (
- '
'
- ) % (
- _("PayPal does not process payments in your event's currency."),
- _("Please check this PayPal page for a complete list of supported currencies.")
- )
-
- if self.event.currency in LOCAL_ONLY_CURRENCIES:
- settings_content += '
%s''
' % (
- _("Your event's currency is supported by PayPal as a payment and balance currency for in-country "
- "accounts only. This means, that the receiving as well as the sending PayPal account must have been "
- "created in the same country and use the same currency. Out of country accounts will not be able to "
- "send any payments.")
- )
+ # Migrate User to PayPal v2
+ self.event.disable_plugin("pretix.plugins.paypal")
+ self.event.enable_plugin("pretix.plugins.paypal2")
+ self.event.save()
return settings_content
@@ -228,8 +202,8 @@ class Paypal(BasePaymentProvider):
client_id=self.settings.connect_client_id,
client_secret=self.settings.connect_secret_key,
openid_client_id=self.settings.connect_client_id,
- openid_client_secret=self.settings.connect_secret_key,
- openid_redirect_uri=urllib.parse.quote(build_global_uri('plugins:paypal:oauth.return')))
+ openid_client_secret=self.settings.connect_secret_key
+ )
else:
paypalrestsdk.set_config(
mode="sandbox" if "sandbox" in self.settings.get('endpoint') else 'live',
diff --git a/src/pretix/plugins/paypal/signals.py b/src/pretix/plugins/paypal/signals.py
index 669838fc00..3c7d36f504 100644
--- a/src/pretix/plugins/paypal/signals.py
+++ b/src/pretix/plugins/paypal/signals.py
@@ -19,17 +19,10 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# .
#
-import json
-from collections import OrderedDict
-from django import forms
from django.dispatch import receiver
-from django.utils.translation import gettext_lazy as _
-from pretix.base.forms import SecretKeySettingsField
-from pretix.base.signals import (
- logentry_display, register_global_settings, register_payment_providers,
-)
+from pretix.base.signals import logentry_display, register_payment_providers
@receiver(register_payment_providers, dispatch_uid="payment_paypal")
@@ -40,46 +33,6 @@ def register_payment_provider(sender, **kwargs):
@receiver(signal=logentry_display, dispatch_uid="paypal_logentry_display")
def pretixcontrol_logentry_display(sender, logentry, **kwargs):
- if logentry.action_type != 'pretix.plugins.paypal.event':
- return
+ from pretix.plugins.paypal2.signals import pretixcontrol_logentry_display
- data = json.loads(logentry.data)
- event_type = data.get('event_type')
- text = None
- plains = {
- 'PAYMENT.SALE.COMPLETED': _('Payment completed.'),
- 'PAYMENT.SALE.DENIED': _('Payment denied.'),
- 'PAYMENT.SALE.REFUNDED': _('Payment refunded.'),
- 'PAYMENT.SALE.REVERSED': _('Payment reversed.'),
- 'PAYMENT.SALE.PENDING': _('Payment pending.'),
- }
-
- if event_type in plains:
- text = plains[event_type]
- else:
- text = event_type
-
- if text:
- return _('PayPal reported an event: {}').format(text)
-
-
-@receiver(register_global_settings, dispatch_uid='paypal_global_settings')
-def register_global_settings(sender, **kwargs):
- return OrderedDict([
- ('payment_paypal_connect_client_id', forms.CharField(
- label=_('PayPal Connect: Client ID'),
- required=False,
- )),
- ('payment_paypal_connect_secret_key', SecretKeySettingsField(
- label=_('PayPal Connect: Secret key'),
- required=False,
- )),
- ('payment_paypal_connect_endpoint', forms.ChoiceField(
- label=_('PayPal Connect Endpoint'),
- initial='live',
- choices=(
- ('live', 'Live'),
- ('sandbox', 'Sandbox'),
- ),
- )),
- ])
+ return pretixcontrol_logentry_display(sender, logentry, **kwargs)
diff --git a/src/pretix/plugins/paypal/urls.py b/src/pretix/plugins/paypal/urls.py
index 361cec20e1..cb373ddf25 100644
--- a/src/pretix/plugins/paypal/urls.py
+++ b/src/pretix/plugins/paypal/urls.py
@@ -21,11 +21,7 @@
#
from django.conf.urls import include, re_path
-from pretix.multidomain import event_url
-
-from .views import (
- abort, oauth_disconnect, oauth_return, redirect_view, success, webhook,
-)
+from .views import abort, oauth_disconnect, redirect_view, success
event_patterns = [
re_path(r'^paypal/', include([
@@ -35,14 +31,10 @@ event_patterns = [
re_path(r'w/(?P[a-zA-Z0-9]{16})/abort/', abort, name='abort'),
re_path(r'w/(?P[a-zA-Z0-9]{16})/return/', success, name='return'),
-
- event_url(r'^webhook/$', webhook, name='webhook', require_live=False),
])),
]
urlpatterns = [
re_path(r'^control/event/(?P[^/]+)/(?P[^/]+)/paypal/disconnect/',
oauth_disconnect, name='oauth.disconnect'),
- re_path(r'^_paypal/webhook/$', webhook, name='webhook'),
- re_path(r'^_paypal/oauth_return/$', oauth_return, name='oauth.return'),
]
diff --git a/src/pretix/plugins/paypal/views.py b/src/pretix/plugins/paypal/views.py
index 8077bfc60d..62b41fcb9d 100644
--- a/src/pretix/plugins/paypal/views.py
+++ b/src/pretix/plugins/paypal/views.py
@@ -42,16 +42,15 @@ from django.contrib import messages
from django.core import signing
from django.db.models import Sum
from django.http import HttpResponse, HttpResponseBadRequest
-from django.shortcuts import get_object_or_404, redirect, render
+from django.shortcuts import redirect, render
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.decorators.clickjacking import xframe_options_exempt
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from django_scopes import scopes_disabled
-from paypalrestsdk.openid_connect import Tokeninfo
-from pretix.base.models import Event, Order, OrderPayment, OrderRefund, Quota
+from pretix.base.models import Order, OrderPayment, OrderRefund, Quota
from pretix.base.payment import PaymentException
from pretix.control.permissions import event_permission_required
from pretix.multidomain.urlreverse import eventreverse
@@ -76,38 +75,6 @@ def redirect_view(request, *args, **kwargs):
return r
-@scopes_disabled()
-def oauth_return(request, *args, **kwargs):
- if 'payment_paypal_oauth_event' not in request.session:
- messages.error(request, _('An error occurred during connecting with PayPal, please try again.'))
- return redirect(reverse('control:index'))
-
- event = get_object_or_404(Event, pk=request.session['payment_paypal_oauth_event'])
-
- prov = Paypal(event)
- prov.init_api()
-
- try:
- tokeninfo = Tokeninfo.create(request.GET.get('code'))
- userinfo = Tokeninfo.create_with_refresh_token(tokeninfo['refresh_token']).userinfo()
- except paypalrestsdk.exceptions.ConnectionError:
- logger.exception('Failed to obtain OAuth token')
- messages.error(request, _('An error occurred during connecting with PayPal, please try again.'))
- else:
- messages.success(request,
- _('Your PayPal account is now connected to pretix. You can change the settings in '
- 'detail below.'))
-
- event.settings.payment_paypal_connect_refresh_token = tokeninfo['refresh_token']
- event.settings.payment_paypal_connect_user_id = userinfo.email
-
- return redirect(reverse('control:event.settings.payment.provider', kwargs={
- 'organizer': event.organizer.slug,
- 'event': event.slug,
- 'provider': 'paypal'
- }))
-
-
def success(request, *args, **kwargs):
pid = request.GET.get('paymentId')
token = request.GET.get('token')
@@ -286,8 +253,14 @@ def oauth_disconnect(request, **kwargs):
request.event.settings.payment_paypal__enabled = False
messages.success(request, _('Your PayPal account has been disconnected.'))
+ # Migrate User to PayPal v2
+ event = request.event
+ event.disable_plugin("pretix.plugins.paypal")
+ event.enable_plugin("pretix.plugins.paypal2")
+ event.save()
+
return redirect(reverse('control:event.settings.payment.provider', kwargs={
'organizer': request.event.organizer.slug,
'event': request.event.slug,
- 'provider': 'paypal'
+ 'provider': 'paypal_settings'
}))
diff --git a/src/pretix/plugins/paypal2/__init__.py b/src/pretix/plugins/paypal2/__init__.py
new file mode 100644
index 0000000000..9fd5bdc500
--- /dev/null
+++ b/src/pretix/plugins/paypal2/__init__.py
@@ -0,0 +1,21 @@
+#
+# This file is part of pretix (Community Edition).
+#
+# Copyright (C) 2014-2020 Raphael Michel and contributors
+# Copyright (C) 2020-2021 rami.io GmbH and contributors
+#
+# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
+# Public License as published by the Free Software Foundation in version 3 of the License.
+#
+# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
+# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
+# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
+# this file, see .
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
+# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
+# .
+#
diff --git a/src/pretix/plugins/paypal2/apps.py b/src/pretix/plugins/paypal2/apps.py
new file mode 100644
index 0000000000..14e5472de1
--- /dev/null
+++ b/src/pretix/plugins/paypal2/apps.py
@@ -0,0 +1,49 @@
+#
+# This file is part of pretix (Community Edition).
+#
+# Copyright (C) 2014-2020 Raphael Michel and contributors
+# Copyright (C) 2020-2021 rami.io GmbH and contributors
+#
+# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
+# Public License as published by the Free Software Foundation in version 3 of the License.
+#
+# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
+# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
+# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
+# this file, see .
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
+# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
+# .
+#
+
+from django.apps import AppConfig
+from django.utils.translation import gettext_lazy as _
+
+from pretix import __version__ as version
+
+
+class Paypal2App(AppConfig):
+ name = 'pretix.plugins.paypal2'
+ verbose_name = "PayPal"
+
+ class PretixPluginMeta:
+ name = "PayPal"
+ author = _("the pretix team")
+ version = version
+ category = 'PAYMENT'
+ featured = True
+ picture = 'pretixplugins/paypal2/paypal_logo.svg'
+ description = _("Accept payments with your PayPal account. In addition to regular PayPal payments, you can now "
+ "also offer payments in a variety of local payment methods such as giropay, SOFORT, iDEAL and "
+ "many more to your customers - they don't even need a PayPal account. PayPal is one of the "
+ "most popular payment methods world-wide.")
+
+ def ready(self):
+ from . import signals # NOQA
+
+ def is_available(self, event):
+ return 'pretix.plugins.paypal' not in event.plugins.split(',')
diff --git a/src/pretix/plugins/paypal2/client/core/environment.py b/src/pretix/plugins/paypal2/client/core/environment.py
new file mode 100644
index 0000000000..2be81982af
--- /dev/null
+++ b/src/pretix/plugins/paypal2/client/core/environment.py
@@ -0,0 +1,66 @@
+#
+# This file is part of pretix (Community Edition).
+#
+# Copyright (C) 2014-2020 Raphael Michel and contributors
+# Copyright (C) 2020-2021 rami.io GmbH and contributors
+#
+# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
+# Public License as published by the Free Software Foundation in version 3 of the License.
+#
+# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
+# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
+# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
+# this file, see .
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
+# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
+# .
+#
+import jwt
+from paypalcheckoutsdk.core import PayPalEnvironment as VendorPayPalEnvironment
+
+
+class PayPalEnvironment(VendorPayPalEnvironment):
+ def __init__(self, client_id, client_secret, api_url, web_url, merchant_id, partner_id):
+ super(PayPalEnvironment, self).__init__(client_id, client_secret, api_url, web_url)
+ self.merchant_id = merchant_id
+ self.partner_id = partner_id
+
+ def authorization_assertation(self):
+ if self.merchant_id:
+ return jwt.encode(
+ payload={
+ 'iss': self.client_id,
+ 'payer_id': self.merchant_id
+ },
+ key=None,
+ algorithm=None,
+ )
+ return ""
+
+
+class SandboxEnvironment(PayPalEnvironment):
+ def __init__(self, client_id, client_secret, merchant_id=None, partner_id=None):
+ super(SandboxEnvironment, self).__init__(
+ client_id,
+ client_secret,
+ PayPalEnvironment.SANDBOX_API_URL,
+ PayPalEnvironment.SANDBOX_WEB_URL,
+ merchant_id,
+ partner_id
+ )
+
+
+class LiveEnvironment(PayPalEnvironment):
+ def __init__(self, client_id, client_secret, merchant_id, partner_id):
+ super(LiveEnvironment, self).__init__(
+ client_id,
+ client_secret,
+ PayPalEnvironment.LIVE_API_URL,
+ PayPalEnvironment.LIVE_WEB_URL,
+ merchant_id,
+ partner_id
+ )
diff --git a/src/pretix/plugins/paypal2/client/core/paypal_http_client.py b/src/pretix/plugins/paypal2/client/core/paypal_http_client.py
new file mode 100644
index 0000000000..1e78f55f9a
--- /dev/null
+++ b/src/pretix/plugins/paypal2/client/core/paypal_http_client.py
@@ -0,0 +1,70 @@
+#
+# This file is part of pretix (Community Edition).
+#
+# Copyright (C) 2014-2020 Raphael Michel and contributors
+# Copyright (C) 2020-2021 rami.io GmbH and contributors
+#
+# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
+# Public License as published by the Free Software Foundation in version 3 of the License.
+#
+# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
+# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
+# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
+# this file, see .
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
+# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
+# .
+#
+import hashlib
+
+from django.core.cache import cache
+from paypalcheckoutsdk.core import (
+ AccessToken, PayPalHttpClient as VendorPayPalHttpClient,
+)
+
+
+class PayPalHttpClient(VendorPayPalHttpClient):
+ def __call__(self, request):
+ # First we get all the items that make up the current credentials and create a hash to detect changes
+
+ checksum = hashlib.sha256(''.join([
+ self.environment.base_url, self.environment.client_id, self.environment.client_secret
+ ]).encode()).hexdigest()
+ cache_key_hash = f'pretix_paypal_token_hash_{checksum}'
+ token_hash = cache.get(cache_key_hash)
+
+ if token_hash:
+ # First we set an optional access token
+ self._access_token = AccessToken(
+ access_token=token_hash['access_token'],
+ expires_in=token_hash['expires_in'],
+ token_type=token_hash['token_type'],
+ )
+ # This is not part of the constructor - so we need to set it after the fact.
+ self._access_token.created_at = token_hash['created_at']
+
+ # Only then we'll call the original __call__() method, as it will verify the validity of the tokens
+ # and request new ones if required.
+ super().__call__(request)
+
+ # At this point - if there were any changes in access-token, we should have them and can cache them again
+ if self._access_token and (not token_hash or token_hash['access_token'] != self._access_token.access_token):
+ expiration = self._access_token.expires_in - 60 # For good measure, we expire 60 seconds earlier
+
+ cache.set(cache_key_hash, {
+ 'access_token': self._access_token.access_token,
+ 'expires_in': self._access_token.expires_in,
+ 'token_type': self._access_token.token_type,
+ 'created_at': self._access_token.created_at
+ }, expiration)
+
+ # And now for some housekeeping.
+ if self.environment.merchant_id:
+ request.headers["PayPal-Auth-Assertion"] = self.environment.authorization_assertation()
+
+ if self.environment.partner_id:
+ request.headers["PayPal-Partner-Attribution-Id"] = self.environment.partner_id
diff --git a/src/pretix/plugins/paypal2/client/customer/partner_referral_create_request.py b/src/pretix/plugins/paypal2/client/customer/partner_referral_create_request.py
new file mode 100644
index 0000000000..b0ca081bac
--- /dev/null
+++ b/src/pretix/plugins/paypal2/client/customer/partner_referral_create_request.py
@@ -0,0 +1,38 @@
+#
+# This file is part of pretix (Community Edition).
+#
+# Copyright (C) 2014-2020 Raphael Michel and contributors
+# Copyright (C) 2020-2021 rami.io GmbH and contributors
+#
+# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
+# Public License as published by the Free Software Foundation in version 3 of the License.
+#
+# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
+# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
+# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
+# this file, see .
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
+# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
+# .
+#
+class PartnerReferralCreateRequest:
+ """
+ Creates a Partner Referral.
+ """
+ def __init__(self):
+ self.verb = "POST"
+ self.path = "/v2/customer/partner-referrals?"
+ self.headers = {}
+ self.headers["Content-Type"] = "application/json"
+ self.body = None
+
+ def prefer(self, prefer):
+ self.headers["Prefer"] = str(prefer)
+
+ def request_body(self, order):
+ self.body = order
+ return self
diff --git a/src/pretix/plugins/paypal2/client/customer/partners_merchantintegrations_get_request.py b/src/pretix/plugins/paypal2/client/customer/partners_merchantintegrations_get_request.py
new file mode 100644
index 0000000000..68ce135e24
--- /dev/null
+++ b/src/pretix/plugins/paypal2/client/customer/partners_merchantintegrations_get_request.py
@@ -0,0 +1,43 @@
+#
+# This file is part of pretix (Community Edition).
+#
+# Copyright (C) 2014-2020 Raphael Michel and contributors
+# Copyright (C) 2020-2021 rami.io GmbH and contributors
+#
+# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
+# Public License as published by the Free Software Foundation in version 3 of the License.
+#
+# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
+# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
+# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
+# this file, see .
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
+# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
+# .
+#
+try:
+ from urllib import quote # Python 2.X
+except ImportError:
+ from urllib.parse import quote # Python 3+
+
+
+class PartnersMerchantIntegrationsGetRequest:
+ """
+ Retrieves the Merchant Account Status of a Partner Merchant Integration.
+ """
+ def __init__(self, partner_merchant_id, seller_merchant_id):
+ self.verb = "GET"
+ self.path = "/v1/customer/partners/{partner_merchant_id}/merchant-integrations/{seller_merchant_id}".format(
+ partner_merchant_id=quote(str(partner_merchant_id)),
+ seller_merchant_id=quote(str(seller_merchant_id))
+ )
+ self.headers = {}
+ self.headers["Content-Type"] = "application/json"
+ self.body = None
+
+ def prefer(self, prefer):
+ self.headers["Prefer"] = str(prefer)
diff --git a/src/pretix/plugins/paypal2/migrations/__init__.py b/src/pretix/plugins/paypal2/migrations/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/pretix/plugins/paypal2/payment.py b/src/pretix/plugins/paypal2/payment.py
new file mode 100644
index 0000000000..2b15d2d7fd
--- /dev/null
+++ b/src/pretix/plugins/paypal2/payment.py
@@ -0,0 +1,978 @@
+#
+# This file is part of pretix (Community Edition).
+#
+# Copyright (C) 2014-2020 Raphael Michel and contributors
+# Copyright (C) 2020-2021 rami.io GmbH and contributors
+#
+# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
+# Public License as published by the Free Software Foundation in version 3 of the License.
+#
+# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
+# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
+# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
+# this file, see .
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
+# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
+# .
+#
+import hashlib
+import json
+import logging
+import urllib.parse
+from collections import OrderedDict
+from decimal import Decimal
+
+from django import forms
+from django.contrib import messages
+from django.http import HttpRequest
+from django.template.loader import get_template
+from django.templatetags.static import static
+from django.urls import reverse
+from django.utils.crypto import get_random_string
+from django.utils.safestring import mark_safe
+from django.utils.timezone import now
+from django.utils.translation import gettext as __, gettext_lazy as _
+from django_countries import countries
+from i18nfield.strings import LazyI18nString
+from paypalcheckoutsdk.orders import (
+ OrdersCaptureRequest, OrdersCreateRequest, OrdersGetRequest,
+ OrdersPatchRequest,
+)
+from paypalcheckoutsdk.payments import CapturesRefundRequest, RefundsGetRequest
+
+from pretix import settings
+from pretix.base.decimal import round_decimal
+from pretix.base.forms.questions import guess_country
+from pretix.base.models import Event, Order, OrderPayment, OrderRefund, Quota
+from pretix.base.payment import BasePaymentProvider, PaymentException
+from pretix.base.services.mail import SendMailException
+from pretix.base.settings import SettingsSandbox
+from pretix.helpers.urls import build_absolute_uri as build_global_uri
+from pretix.multidomain.urlreverse import build_absolute_uri, eventreverse
+from pretix.plugins.paypal2.client.core.environment import (
+ LiveEnvironment, SandboxEnvironment,
+)
+from pretix.plugins.paypal2.client.core.paypal_http_client import (
+ PayPalHttpClient,
+)
+from pretix.plugins.paypal2.client.customer.partner_referral_create_request import (
+ PartnerReferralCreateRequest,
+)
+from pretix.plugins.paypal.models import ReferencedPayPalObject
+
+logger = logging.getLogger('pretix.plugins.paypal2')
+
+SUPPORTED_CURRENCIES = ['AUD', 'BRL', 'CAD', 'CZK', 'DKK', 'EUR', 'HKD', 'HUF', 'INR', 'ILS', 'JPY', 'MYR', 'MXN',
+ 'TWD', 'NZD', 'NOK', 'PHP', 'PLN', 'GBP', 'RUB', 'SGD', 'SEK', 'CHF', 'THB', 'USD']
+
+LOCAL_ONLY_CURRENCIES = ['INR']
+
+
+class PaypalSettingsHolder(BasePaymentProvider):
+ identifier = 'paypal_settings'
+ verbose_name = _('PayPal')
+ is_enabled = False
+ is_meta = True
+ payment_form_fields = OrderedDict([])
+
+ def __init__(self, event: Event):
+ super().__init__(event)
+ self.settings = SettingsSandbox('payment', 'paypal', event)
+
+ @property
+ def settings_form_fields(self):
+ # ISU
+ if self.settings.connect_client_id and self.settings.connect_secret_key and not self.settings.secret:
+ if self.settings.isu_merchant_id:
+ fields = [
+ ('isu_merchant_id',
+ forms.CharField(
+ label=_('PayPal Merchant ID'),
+ disabled=True
+ )),
+ ]
+ else:
+ return {}
+ # Manual API integration
+ else:
+ fields = [
+ ('client_id',
+ forms.CharField(
+ label=_('Client ID'),
+ max_length=80,
+ min_length=80,
+ help_text=_('{text}').format(
+ text=_('Click here for a tutorial on how to obtain the required keys'),
+ docs_url='https://docs.pretix.eu/en/latest/user/payments/paypal.html'
+ )
+ )),
+ ('secret',
+ forms.CharField(
+ label=_('Secret'),
+ max_length=80,
+ min_length=80,
+ )),
+ ('endpoint',
+ forms.ChoiceField(
+ label=_('Endpoint'),
+ initial='live',
+ choices=(
+ ('live', 'Live'),
+ ('sandbox', 'Sandbox'),
+ ),
+ )),
+ ]
+
+ methods = [
+ ('method_wallet',
+ forms.BooleanField(
+ label=_('PayPal'),
+ required=False,
+ help_text=_(
+ 'Even if a customer chooses an Alternative Payment Method, they will always have the option to '
+ 'revert back to paying with their PayPal account. For this reason, this payment method is always '
+ 'active.'
+ ),
+ disabled=True,
+ )),
+ ('method_apm',
+ forms.BooleanField(
+ label=_('Alternative Payment Methods'),
+ help_text=_(
+ 'In addition to payments through a PayPal account, you can also offer your customers the option '
+ 'to pay with credit cards and other, local payment methods such as SOFORT, giropay, iDEAL, and '
+ 'many more - even when they do not have a PayPal account. Eligible payment methods will be '
+ 'determined based on the shoppers location. For German merchants, this is the direct successor '
+ 'of PayPal Plus.'
+ ),
+ required=False,
+ widget=forms.CheckboxInput(
+ attrs={
+ 'data-checkbox-dependency': '#id_payment_paypal_method_wallet',
+ }
+ )
+ )),
+ ('disable_method_sepa',
+ forms.BooleanField(
+ label=_('Disable SEPA Direct Debit'),
+ help_text=_(
+ 'While most payment methods cannot be recalled by a customer without outlining their exact grief '
+ 'with the merchants, SEPA Direct Debit can be recalled with the press of a button. For that '
+ 'reason - and depending on the nature of your event - you might want to disabled the option of '
+ 'SEPA Direct Debit payments in order to reduce the risk of costly chargebacks.'
+ ),
+ required=False,
+ widget=forms.CheckboxInput(
+ attrs={
+ 'data-checkbox-dependency': '#id_payment_paypal_method_apm',
+ }
+ )
+ )),
+ ('enable_method_paylater',
+ forms.BooleanField(
+ label=_('Enable Buy Now Pay Later'),
+ help_text=_(
+ 'Offer your customers the possibility to buy now (up to a certain limit) and pay in multiple installments '
+ 'or within 30 days. You, as the merchant, are getting your money right away.'
+ ),
+ required=False,
+ widget=forms.CheckboxInput(
+ attrs={
+ 'data-checkbox-dependency': '#id_payment_paypal_method_apm',
+ }
+ )
+ )),
+
+ ]
+
+ extra_fields = [
+ ('prefix',
+ forms.CharField(
+ label=_('Reference prefix'),
+ help_text=_('Any value entered here will be added in front of the regular booking reference '
+ 'containing the order number.'),
+ required=False,
+ )),
+ ('postfix',
+ forms.CharField(
+ label=_('Reference postfix'),
+ help_text=_('Any value entered here will be added behind the regular booking reference '
+ 'containing the order number.'),
+ required=False,
+ )),
+ ]
+
+ if settings.DEBUG:
+ allcountries = list(countries)
+ allcountries.insert(0, ('', _('-- Automatic --')))
+
+ extra_fields.append(
+ ('debug_buyer_country',
+ forms.ChoiceField(
+ choices=allcountries,
+ label=mark_safe('DEBUG {}'.format(_('Buyer country'))),
+ initial=guess_country(self.event),
+ )),
+ )
+
+ d = OrderedDict(
+ fields + methods + extra_fields + list(super().settings_form_fields.items())
+ )
+
+ d.move_to_end('prefix')
+ d.move_to_end('postfix')
+ d.move_to_end('_enabled', False)
+ return d
+
+ def settings_content_render(self, request):
+ settings_content = ""
+ if self.settings.connect_client_id and self.settings.connect_secret_key and not self.settings.secret:
+ # Use ISU
+ if not self.settings.isu_merchant_id:
+ isu_referral_url = self.get_isu_referral_url(request)
+ settings_content = (
+ "
{}
"
+ "{}"
+ ).format(
+ _('To accept payments via PayPal, you will need an account at PayPal. By clicking on the '
+ 'following button, you can either create a new PayPal account or connect pretix to an existing '
+ 'one.'),
+ isu_referral_url,
+ 'disabled' if not isu_referral_url else '',
+ _('Connect with {icon} PayPal').format(icon='')
+ )
+ else:
+ settings_content = (
+ ""
+ ).format(
+ reverse('plugins:paypal2:isu.disconnect', kwargs={
+ 'organizer': self.event.organizer.slug,
+ 'event': self.event.slug,
+ }),
+ _('Disconnect from PayPal')
+ )
+ else:
+ settings_content = "
%s %s
" % (
+ _('Please configure a PayPal Webhook to the following endpoint in order to automatically cancel orders '
+ 'when payments are refunded externally.'),
+ build_global_uri('plugins:paypal2:webhook')
+ )
+
+ if self.event.currency not in SUPPORTED_CURRENCIES:
+ settings_content += (
+ '
'
+ ) % (
+ _("PayPal does not process payments in your event's currency."),
+ _("Please check this PayPal page for a complete list of supported currencies.")
+ )
+
+ if self.event.currency in LOCAL_ONLY_CURRENCIES:
+ settings_content += '
%s''
' % (
+ _("Your event's currency is supported by PayPal as a payment and balance currency for in-country "
+ "accounts only. This means, that the receiving as well as the sending PayPal account must have been "
+ "created in the same country and use the same currency. Out of country accounts will not be able to "
+ "send any payments.")
+ )
+
+ return settings_content
+
+ def get_isu_referral_url(self, request):
+ pprov = PaypalMethod(request.event)
+ pprov.init_api()
+
+ request.session['payment_paypal_isu_event'] = request.event.pk
+ request.session['payment_paypal_isu_tracking_id'] = get_random_string(length=127)
+
+ try:
+ req = PartnerReferralCreateRequest()
+
+ req.request_body({
+ "operations": [
+ {
+ "operation": "API_INTEGRATION",
+ "api_integration_preference": {
+ "rest_api_integration": {
+ "integration_method": "PAYPAL",
+ "integration_type": "THIRD_PARTY",
+ "third_party_details": {
+ "features": [
+ "PAYMENT",
+ "REFUND",
+ "ACCESS_MERCHANT_INFORMATION"
+ ],
+ }
+ }
+ }
+ }
+ ],
+ "products": [
+ "EXPRESS_CHECKOUT"
+ ],
+ "partner_config_override": {
+ "partner_logo_url": urllib.parse.urljoin(settings.SITE_URL, static('pretixbase/img/pretix-logo.svg')),
+ "return_url": build_global_uri('plugins:paypal2:isu.return', kwargs={
+ 'organizer': self.event.organizer.slug,
+ 'event': self.event.slug,
+ })
+ },
+ "tracking_id": request.session['payment_paypal_isu_tracking_id'],
+ "preferred_language_code": request.user.locale.split('-')[0]
+ })
+ response = pprov.client.execute(req)
+ except IOError as e:
+ messages.error(request, _('An error occurred during connecting with PayPal, please try again.'))
+ logger.exception('PayPal PartnerReferralCreateRequest: {}'.format(str(e)))
+ else:
+ return self.get_link(response.result.links, 'action_url').href
+
+ def get_link(self, links, rel):
+ for link in links:
+ if link.rel == rel:
+ return link
+
+ return None
+
+
+class PaypalMethod(BasePaymentProvider):
+ identifier = ''
+ method = ''
+ BN = 'ramiioGmbH_Cart_PPCP'
+
+ def __init__(self, event: Event):
+ super().__init__(event)
+ self.settings = SettingsSandbox('payment', 'paypal', event)
+
+ @property
+ def settings_form_fields(self):
+ return {}
+
+ @property
+ def is_enabled(self) -> bool:
+ return self.settings.get('_enabled', as_type=bool) and self.settings.get('method_{}'.format(self.method),
+ as_type=bool)
+
+ @property
+ def test_mode_message(self):
+ if self.settings.connect_client_id and not self.settings.secret:
+ # in OAuth mode, sandbox mode needs to be set global
+ is_sandbox = self.settings.connect_endpoint == 'sandbox'
+ else:
+ is_sandbox = self.settings.get('endpoint') == 'sandbox'
+ if is_sandbox:
+ return _('The PayPal sandbox is being used, you can test without actually sending money but you will need a '
+ 'PayPal sandbox user to log in.')
+ return None
+
+ def is_allowed(self, request: HttpRequest, total: Decimal = None) -> bool:
+ return super().is_allowed(request, total) and self.event.currency in SUPPORTED_CURRENCIES
+
+ def init_api(self):
+ # ISU
+ if self.settings.connect_client_id and not self.settings.secret:
+ if 'sandbox' in self.settings.connect_endpoint:
+ env = SandboxEnvironment(
+ client_id=self.settings.connect_client_id,
+ client_secret=self.settings.connect_secret_key,
+ merchant_id=self.settings.get('isu_merchant_id', None),
+ partner_id=self.BN
+ )
+ else:
+ env = LiveEnvironment(
+ client_id=self.settings.connect_client_id,
+ client_secret=self.settings.connect_secret_key,
+ merchant_id=self.settings.get('isu_merchant_id', None),
+ partner_id=self.BN
+ )
+ # Manual API integration
+ else:
+ if 'sandbox' in self.settings.get('endpoint'):
+ env = SandboxEnvironment(
+ client_id=self.settings.get('client_id'),
+ client_secret=self.settings.get('secret'),
+ merchant_id=None,
+ partner_id=self.BN
+ )
+ else:
+ env = LiveEnvironment(
+ client_id=self.settings.get('client_id'),
+ client_secret=self.settings.get('secret'),
+ merchant_id=None,
+ partner_id=self.BN
+ )
+
+ self.client = PayPalHttpClient(env)
+
+ def payment_is_valid_session(self, request):
+ return request.session.get('payment_paypal_oid', '') != ''
+
+ def payment_form_render(self, request) -> str:
+ def build_kwargs():
+ keys = ['organizer', 'event', 'order', 'secret', 'cart_namespace']
+ kwargs = {}
+ for key in keys:
+ if key in request.resolver_match.kwargs:
+ kwargs[key] = request.resolver_match.kwargs[key]
+ return kwargs
+
+ template = get_template('pretixplugins/paypal2/checkout_payment_form.html')
+ ctx = {
+ 'request': request,
+ 'event': self.event,
+ 'settings': self.settings,
+ 'method': self.method,
+ 'xhr': eventreverse(self.event, 'plugins:paypal2:xhr', kwargs=build_kwargs())
+ }
+ return template.render(ctx)
+
+ def checkout_prepare(self, request, cart):
+ paypal_order_id = request.POST.get('payment_paypal_{}_oid'.format(self.method), None)
+
+ # PayPal OID has been previously generated through XHR and onApprove() has fired
+ if paypal_order_id and paypal_order_id == request.session.get('payment_paypal_oid', None):
+ self.init_api()
+
+ try:
+ req = OrdersGetRequest(paypal_order_id)
+ response = self.client.execute(req)
+ except IOError as e:
+ messages.warning(request, _('We had trouble communicating with PayPal'))
+ logger.exception('PayPal OrdersGetRequest: {}'.format(str(e)))
+ return False
+ else:
+ if response.result.status == 'APPROVED':
+ return True
+ messages.warning(request, _('Something went wrong when requesting the payment status. Please try again.'))
+ return False
+ # onApprove has fired, but we don't have a matching OID in the session - manipulation/something went wrong.
+ elif paypal_order_id:
+ messages.warning(request, _('We had trouble communicating with PayPal'))
+ return False
+ else:
+ # We don't have an XHR-generated OID, nor a onApprove-fired OID.
+ # Probably no active JavaScript; this won't work
+ messages.warning(request, _('You may need to enable JavaScript for PayPal payments.'))
+ return False
+
+ def format_price(self, value):
+ return str(round_decimal(value, self.event.currency, {
+ # PayPal behaves differently than Stripe in deciding what currencies have decimal places
+ # Source https://developer.paypal.com/docs/classic/api/currency_codes/
+ 'HUF': 0,
+ 'JPY': 0,
+ 'MYR': 0,
+ 'TWD': 0,
+ # However, CLPs are not listed there while PayPal requires us not to send decimal places there. WTF.
+ 'CLP': 0,
+ # Let's just guess that the ones listed here are 0-based as well
+ # https://developers.braintreepayments.com/reference/general/currencies
+ 'BIF': 0,
+ 'DJF': 0,
+ 'GNF': 0,
+ 'KMF': 0,
+ 'KRW': 0,
+ 'LAK': 0,
+ 'PYG': 0,
+ 'RWF': 0,
+ 'UGX': 0,
+ 'VND': 0,
+ 'VUV': 0,
+ 'XAF': 0,
+ 'XOF': 0,
+ 'XPF': 0,
+ }))
+
+ @property
+ def abort_pending_allowed(self):
+ return False
+
+ def _create_paypal_order(self, request, payment=None, cart=None):
+ self.init_api()
+ kwargs = {}
+ if request.resolver_match and 'cart_namespace' in request.resolver_match.kwargs:
+ kwargs['cart_namespace'] = request.resolver_match.kwargs['cart_namespace']
+
+ # ISU
+ if request.event.settings.payment_paypal_isu_merchant_id:
+ payee = {
+ "merchant_id": request.event.settings.payment_paypal_isu_merchant_id,
+ }
+ # Manual API integration
+ else:
+ payee = {}
+
+ if payment and not cart:
+ value = self.format_price(payment.amount)
+ currency = payment.order.event.currency
+ description = '{prefix}{orderstring}{postfix}'.format(
+ prefix='{} '.format(self.settings.prefix) if self.settings.prefix else '',
+ orderstring=__('Order {order} for {event}').format(
+ event=request.event.name,
+ order=payment.order.code
+ ),
+ postfix=' {}'.format(self.settings.postfix) if self.settings.postfix else ''
+ )
+ custom_id = '{prefix}{slug}-{code}{postfix}'.format(
+ prefix='{} '.format(self.settings.prefix) if self.settings.prefix else '',
+ slug=self.event.slug.upper(),
+ code=payment.order.code,
+ postfix=' {}'.format(self.settings.postfix) if self.settings.postfix else ''
+ )
+ request.session['payment_paypal_payment'] = payment.pk
+ elif cart and not payment:
+ value = self.format_price(cart['total'] + cart['fee'])
+ currency = request.event.currency
+ description = __('Event tickets for {event}').format(event=request.event.name)
+ custom_id = '{prefix}{slug}{postfix}'.format(
+ prefix='{} '.format(self.settings.prefix) if self.settings.prefix else '',
+ slug=request.event.slug.upper(),
+ postfix=' {}'.format(self.settings.postfix) if self.settings.postfix else ''
+ )
+ request.session['payment_paypal_payment'] = None
+ else:
+ pass
+
+ try:
+ paymentreq = OrdersCreateRequest()
+ paymentreq.request_body({
+ 'intent': 'CAPTURE',
+ # 'payer': {}, # We could transmit PII (email, name, address, etc.)
+ 'purchase_units': [{
+ 'amount': {
+ 'currency_code': currency,
+ 'value': value,
+ },
+ 'payee': payee,
+ 'description': description,
+ 'custom_id': custom_id,
+ # 'shipping': {}, # Include Shipping information?
+ }],
+ 'application_context': {
+ 'locale': request.LANGUAGE_CODE,
+ 'shipping_preference': 'NO_SHIPPING', # 'SET_PROVIDED_ADDRESS', # Do not set on non-ship order?
+ 'user_action': 'CONTINUE',
+ 'return_url': build_absolute_uri(request.event, 'plugins:paypal2:return', kwargs=kwargs),
+ 'cancel_url': build_absolute_uri(request.event, 'plugins:paypal2:abort', kwargs=kwargs),
+ },
+ })
+ response = self.client.execute(paymentreq)
+ except IOError as e:
+ messages.error(request, _('We had trouble communicating with PayPal'))
+ logger.exception('PayPal OrdersCreateRequest: {}'.format(str(e)))
+ else:
+ if response.result.status not in ('CREATED', 'PAYER_ACTION_REQUIRED'):
+ messages.error(request, _('We had trouble communicating with PayPal'))
+ logger.error('Invalid payment state: ' + str(paymentreq))
+ return
+
+ request.session['payment_paypal_oid'] = response.result.id
+ return response.result
+
+ def checkout_confirm_render(self, request) -> str:
+ """
+ Returns the HTML that should be displayed when the user selected this provider
+ on the 'confirm order' page.
+ """
+ template = get_template('pretixplugins/paypal2/checkout_payment_confirm.html')
+ ctx = {'request': request, 'event': self.event, 'settings': self.settings}
+ return template.render(ctx)
+
+ def execute_payment(self, request: HttpRequest, payment: OrderPayment):
+ try:
+ if request.session.get('payment_paypal_oid', '') == '':
+ raise PaymentException(_('We were unable to process your payment. See below for details on how to '
+ 'proceed.'))
+
+ self.init_api()
+ try:
+ req = OrdersGetRequest(request.session.get('payment_paypal_oid'))
+ response = self.client.execute(req)
+ except IOError as e:
+ logger.exception('PayPal OrdersGetRequest: {}'.format(str(e)))
+ raise PaymentException(_('We had trouble communicating with PayPal'))
+ else:
+ pp_captured_order = response.result
+
+ try:
+ ReferencedPayPalObject.objects.get_or_create(order=payment.order, payment=payment, reference=pp_captured_order.id)
+ except ReferencedPayPalObject.MultipleObjectsReturned:
+ pass
+ if str(pp_captured_order.purchase_units[0].amount.value) != str(payment.amount) or \
+ pp_captured_order.purchase_units[0].amount.currency_code != self.event.currency:
+ logger.error('Value mismatch: Payment %s vs paypal trans %s' % (payment.id, str(pp_captured_order)))
+ raise PaymentException(_('We were unable to process your payment. See below for details on how to '
+ 'proceed.'))
+
+ if pp_captured_order.status == 'APPROVED':
+ try:
+ patchreq = OrdersPatchRequest(pp_captured_order.id)
+ patchreq.request_body([
+ {
+ "op": "replace",
+ "path": "/purchase_units/@reference_id=='default'/custom_id",
+ "value": '{prefix}{orderstring}{postfix}'.format(
+ prefix='{} '.format(self.settings.prefix) if self.settings.prefix else '',
+ orderstring=__('Order {slug}-{code}').format(
+ slug=self.event.slug.upper(),
+ code=payment.order.code
+ ),
+ postfix=' {}'.format(self.settings.postfix) if self.settings.postfix else ''
+ ),
+ },
+ {
+ "op": "replace",
+ "path": "/purchase_units/@reference_id=='default'/description",
+ "value": '{prefix}{orderstring}{postfix}'.format(
+ prefix='{} '.format(self.settings.prefix) if self.settings.prefix else '',
+ orderstring=__('Order {order} for {event}').format(
+ event=request.event.name,
+ order=payment.order.code
+ ),
+ postfix=' {}'.format(self.settings.postfix) if self.settings.postfix else ''
+ ),
+ }
+ ])
+ self.client.execute(patchreq)
+ except IOError as e:
+ messages.error(request, _('We had trouble communicating with PayPal'))
+ logger.exception('PayPal OrdersPatchRequest: {}'.format(str(e)))
+ return
+
+ try:
+ capturereq = OrdersCaptureRequest(pp_captured_order.id)
+ response = self.client.execute(capturereq)
+ except IOError as e:
+ messages.error(request, _('We had trouble communicating with PayPal'))
+ logger.exception('PayPal OrdersCaptureRequest: {}'.format(str(e)))
+ return
+ else:
+ pp_captured_order = response.result
+
+ for purchaseunit in pp_captured_order.purchase_units:
+ for capture in purchaseunit.payments.captures:
+ try:
+ ReferencedPayPalObject.objects.get_or_create(order=payment.order, payment=payment, reference=capture.id)
+ except ReferencedPayPalObject.MultipleObjectsReturned:
+ pass
+
+ if capture.status == 'PENDING':
+ messages.warning(request, _('PayPal has not yet approved the payment. We will inform you as '
+ 'soon as the payment completed.'))
+ payment.info = json.dumps(pp_captured_order.dict())
+ payment.state = OrderPayment.PAYMENT_STATE_PENDING
+ payment.save()
+ return
+
+ payment.refresh_from_db()
+
+ if pp_captured_order.status != 'COMPLETED':
+ payment.fail(info=pp_captured_order.dict())
+ logger.error('Invalid state: %s' % str(pp_captured_order))
+ raise PaymentException(
+ _('We were unable to process your payment. See below for details on how to proceed.')
+ )
+
+ if payment.state == OrderPayment.PAYMENT_STATE_CONFIRMED:
+ logger.warning('PayPal success event even though order is already marked as paid')
+ return
+
+ try:
+ payment.info = json.dumps(pp_captured_order.dict())
+ payment.save(update_fields=['info'])
+ payment.confirm()
+ except Quota.QuotaExceededException as e:
+ raise PaymentException(str(e))
+
+ except SendMailException:
+ messages.warning(request, _('There was an error sending the confirmation mail.'))
+ finally:
+ del request.session['payment_paypal_oid']
+
+ def payment_pending_render(self, request, payment) -> str:
+ retry = True
+ try:
+ if payment.info and payment.info_data['state'] == 'pending':
+ retry = False
+ except KeyError:
+ pass
+ template = get_template('pretixplugins/paypal2/pending.html')
+ ctx = {'request': request, 'event': self.event, 'settings': self.settings,
+ 'retry': retry, 'order': payment.order}
+ return template.render(ctx)
+
+ def matching_id(self, payment: OrderPayment):
+ sale_id = None
+
+ # Legacy PayPal info-data
+ if 'purchase_units' not in payment.info_data:
+ for trans in payment.info_data.get('transactions', []):
+ for res in trans.get('related_resources', []):
+ if 'sale' in res and 'id' in res['sale']:
+ sale_id = res['sale']['id']
+ else:
+ for trans in payment.info_data.get('purchase_units', []):
+ for res in trans.get('payments', {}).get('captures', []):
+ sale_id = res['id']
+
+ return sale_id or payment.info_data.get('id', None)
+
+ def api_payment_details(self, payment: OrderPayment):
+ sale_id = None
+
+ # Legacy PayPal info-data
+ if 'purchase_units' not in payment.info_data:
+ for trans in payment.info_data.get('transactions', []):
+ for res in trans.get('related_resources', []):
+ if 'sale' in res and 'id' in res['sale']:
+ sale_id = res['sale']['id']
+
+ return {
+ "payer_email": payment.info_data.get('payer', {}).get('payer_info', {}).get('email'),
+ "payer_id": payment.info_data.get('payer', {}).get('payer_info', {}).get('payer_id'),
+ "cart_id": payment.info_data.get('cart', None),
+ "payment_id": payment.info_data.get('id', None),
+ "sale_id": sale_id,
+ }
+ else:
+ for trans in payment.info_data.get('purchase_units', []):
+ for res in trans.get('payments', {}).get('captures', []):
+ sale_id = res['id']
+
+ return {
+ "payer_email": payment.info_data.get('payer', {}).get('email_address'),
+ "payer_id": payment.info_data.get('payer', {}).get('payer_id'),
+ "cart_id": payment.info_data.get('id', None),
+ "payment_id": sale_id,
+ "sale_id": sale_id,
+ }
+
+ def payment_control_render(self, request: HttpRequest, payment: OrderPayment):
+ # Legacy PayPal info-data
+ if 'purchase_units' not in payment.info_data:
+ template = get_template('pretixplugins/paypal2/control_legacy.html')
+ sale_id = None
+ for trans in payment.info_data.get('transactions', []):
+ for res in trans.get('related_resources', []):
+ if 'sale' in res and 'id' in res['sale']:
+ sale_id = res['sale']['id']
+ ctx = {'request': request, 'event': self.event, 'settings': self.settings,
+ 'payment_info': payment.info_data, 'order': payment.order, 'sale_id': sale_id}
+ else:
+ template = get_template('pretixplugins/paypal2/control.html')
+ ctx = {'request': request, 'event': self.event, 'settings': self.settings,
+ 'payment_info': payment.info_data, 'order': payment.order}
+
+ return template.render(ctx)
+
+ def payment_control_render_short(self, payment: OrderPayment) -> str:
+ # Legacy PayPal info-data
+ if 'purchase_units' not in payment.info_data:
+ return payment.info_data.get('payer', {}).get('payer_info', {}).get('email', '')
+ else:
+ return '{} / {}'.format(
+ payment.info_data.get('id', ''),
+ payment.info_data.get('payer', {}).get('email_address', '')
+ )
+
+ def payment_partial_refund_supported(self, payment: OrderPayment):
+ # Paypal refunds are possible for 180 days after purchase:
+ # https://www.paypal.com/lc/smarthelp/article/how-do-i-issue-a-refund-faq780#:~:text=Refund%20after%20180%20days%20of,PayPal%20balance%20of%20the%20recipient.
+ return (now() - payment.payment_date).days <= 180
+
+ def payment_refund_supported(self, payment: OrderPayment):
+ self.payment_partial_refund_supported(payment)
+
+ def execute_refund(self, refund: OrderRefund):
+ self.init_api()
+
+ try:
+ pp_payment = None
+ payment_info_data = None
+ # Legacy PayPal - get up to date info data first
+ if "purchase_units" not in refund.payment.info_data:
+ req = OrdersGetRequest(refund.payment.info_data['cart'])
+ response = self.client.execute(req)
+ payment_info_data = response.result.dict()
+ else:
+ payment_info_data = refund.payment.info_data
+
+ for res in payment_info_data['purchase_units'][0]['payments']['captures']:
+ if res['status'] in ['COMPLETED', 'PARTIALLY_REFUNDED']:
+ pp_payment = res['id']
+ break
+
+ if not pp_payment:
+ req = OrdersGetRequest(payment_info_data['id'])
+ response = self.client.execute(req)
+ for res in response.result.purchase_units[0].payments.captures:
+ if res['status'] in ['COMPLETED', 'PARTIALLY_REFUNDED']:
+ pp_payment = res.id
+ break
+
+ req = CapturesRefundRequest(pp_payment)
+ req.request_body({
+ "amount": {
+ "value": self.format_price(refund.amount),
+ "currency_code": refund.order.event.currency
+ }
+ })
+ response = self.client.execute(req)
+ except IOError as e:
+ refund.order.log_action('pretix.event.order.refund.failed', {
+ 'local_id': refund.local_id,
+ 'provider': refund.provider,
+ 'error': str(e)
+ })
+ logger.error('execute_refund: {}'.format(str(e)))
+ raise PaymentException(_('Refunding the amount via PayPal failed: {}').format(str(e)))
+
+ refund.info = json.dumps(response.result.dict())
+ refund.save(update_fields=['info'])
+
+ req = RefundsGetRequest(response.result.id)
+ response = self.client.execute(req)
+ refund.info = json.dumps(response.result.dict())
+ refund.save(update_fields=['info'])
+
+ if response.result.status == 'COMPLETED':
+ refund.done()
+ elif response.result.status == 'PENDING':
+ refund.state = OrderRefund.REFUND_STATE_TRANSIT
+ refund.save(update_fields=['state'])
+ else:
+ refund.order.log_action('pretix.event.order.refund.failed', {
+ 'local_id': refund.local_id,
+ 'provider': refund.provider,
+ 'error': str(response.result.status_details.reason)
+ })
+ raise PaymentException(_('Refunding the amount via PayPal failed: {}').format(response.result.status_details.reason))
+
+ def payment_prepare(self, request, payment):
+ paypal_order_id = request.POST.get('payment_paypal_{}_oid'.format(self.method), None)
+
+ # PayPal OID has been previously generated through XHR and onApprove() has fired
+ if paypal_order_id and paypal_order_id == request.session.get('payment_paypal_oid', None):
+ self.init_api()
+
+ try:
+ req = OrdersGetRequest(paypal_order_id)
+ response = self.client.execute(req)
+ except IOError as e:
+ messages.warning(request, _('We had trouble communicating with PayPal'))
+ logger.exception('PayPal OrdersGetRequest: {}'.format(str(e)))
+ return False
+ else:
+ if response.result.status == 'APPROVED':
+ return True
+ messages.warning(request, _('Something went wrong when requesting the payment status. Please try again.'))
+ return False
+ # onApprove has fired, but we don't have a matching OID in the session - manipulation/something went wrong.
+ elif paypal_order_id:
+ messages.warning(request, _('We had trouble communicating with PayPal'))
+ return False
+ else:
+ # We don't have an XHR-generated OID, nor a onApprove-fired OID.
+ # Probably no active JavaScript; this won't work
+ messages.warning(request, _('You may need to enable JavaScript for PayPal payments.'))
+ return False
+
+ def shred_payment_info(self, obj):
+ if obj.info:
+ d = json.loads(obj.info)
+ new = {
+ 'id': d.get('id'),
+ 'payer': {
+ 'payer_info': {
+ 'email': 'â–ˆ'
+ }
+ },
+ 'update_time': d.get('update_time'),
+ 'transactions': [
+ {
+ 'amount': t.get('amount')
+ } for t in d.get('transactions', [])
+ ],
+ '_shredded': True
+ }
+ obj.info = json.dumps(new)
+ obj.save(update_fields=['info'])
+
+ for le in obj.order.all_logentries().filter(action_type="pretix.plugins.paypal.event").exclude(data=""):
+ d = le.parsed_data
+ if 'resource' in d:
+ d['resource'] = {
+ 'id': d['resource'].get('id'),
+ 'sale_id': d['resource'].get('sale_id'),
+ 'parent_payment': d['resource'].get('parent_payment'),
+ }
+ le.data = json.dumps(d)
+ le.shredded = True
+ le.save(update_fields=['data', 'shredded'])
+
+ def render_invoice_text(self, order: Order, payment: OrderPayment) -> str:
+ if order.status == Order.STATUS_PAID:
+ if payment.info_data.get('id', None):
+ try:
+ return '{}\r\n{}: {}\r\n{}: {}'.format(
+ _('The payment for this invoice has already been received.'),
+ _('PayPal payment ID'),
+ payment.info_data['id'],
+ _('PayPal sale ID'),
+ payment.info_data['transactions'][0]['related_resources'][0]['sale']['id']
+ )
+ except (KeyError, IndexError):
+ return '{}\r\n{}: {}'.format(
+ _('The payment for this invoice has already been received.'),
+ _('PayPal payment ID'),
+ payment.info_data['id']
+ )
+ else:
+ return super().render_invoice_text(order, payment)
+
+ return self.settings.get('_invoice_text', as_type=LazyI18nString, default='')
+
+
+class PaypalWallet(PaypalMethod):
+ identifier = 'paypal'
+ verbose_name = _('PayPal')
+ public_name = _('PayPal')
+ method = 'wallet'
+
+
+class PaypalAPM(PaypalMethod):
+ identifier = 'paypal_apm'
+ verbose_name = _('PayPal APM')
+ public_name = _('PayPal Alternative Payment Methods')
+ method = 'apm'
+
+ def payment_is_valid_session(self, request):
+ # Since APMs request the OID by XHR at a later point, no need to check anything here
+ return True
+
+ def checkout_prepare(self, request, cart):
+ return True
+
+ def payment_prepare(self, request, payment):
+ return True
+
+ def execute_payment(self, request: HttpRequest, payment: OrderPayment):
+ # This is a workaround to not have APMs be written to the database with identifier paypal_apm.
+ # Since all transactions - APM or not - look the same and are handled the same, we want to keep all PayPal
+ # transactions under the "paypal"-identifier - no matter what the customer might have selected.
+ payment.provider = "paypal"
+ payment.save(update_fields=["provider"])
+
+ paypal_order = self._create_paypal_order(request, payment, None)
+ payment.info = json.dumps(paypal_order.dict())
+ payment.save(update_fields=['info'])
+
+ return eventreverse(self.event, 'plugins:paypal2:pay', kwargs={
+ 'order': payment.order.code,
+ 'payment': payment.pk,
+ 'hash': hashlib.sha1(payment.order.secret.lower().encode()).hexdigest(),
+ })
diff --git a/src/pretix/plugins/paypal2/signals.py b/src/pretix/plugins/paypal2/signals.py
new file mode 100644
index 0000000000..e561c3bca5
--- /dev/null
+++ b/src/pretix/plugins/paypal2/signals.py
@@ -0,0 +1,170 @@
+#
+# This file is part of pretix (Community Edition).
+#
+# Copyright (C) 2014-2020 Raphael Michel and contributors
+# Copyright (C) 2020-2021 rami.io GmbH and contributors
+#
+# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
+# Public License as published by the Free Software Foundation in version 3 of the License.
+#
+# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
+# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
+# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
+# this file, see .
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
+# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
+# .
+#
+import json
+from collections import OrderedDict
+
+from django import forms
+from django.dispatch import receiver
+from django.http import HttpRequest, HttpResponse
+from django.template.loader import get_template
+from django.urls import resolve
+from django.utils.crypto import get_random_string
+from django.utils.translation import gettext_lazy as _
+
+from pretix import settings
+from pretix.base.forms import SecretKeySettingsField
+from pretix.base.middleware import _merge_csp, _parse_csp, _render_csp
+from pretix.base.settings import settings_hierarkey
+from pretix.base.signals import (
+ logentry_display, register_global_settings, register_payment_providers,
+)
+from pretix.plugins.paypal2.payment import PaypalMethod
+from pretix.presale.signals import html_head, process_response
+
+
+@receiver(register_payment_providers, dispatch_uid="payment_paypal2")
+def register_payment_provider(sender, **kwargs):
+ from .payment import PaypalAPM, PaypalSettingsHolder, PaypalWallet
+ return [PaypalSettingsHolder, PaypalWallet, PaypalAPM]
+
+
+@receiver(signal=logentry_display, dispatch_uid="paypal2_logentry_display")
+def pretixcontrol_logentry_display(sender, logentry, **kwargs):
+ if logentry.action_type != 'pretix.plugins.paypal.event':
+ return
+
+ data = json.loads(logentry.data)
+ event_type = data.get('event_type')
+ text = None
+ plains = {
+ 'PAYMENT.SALE.COMPLETED': _('Payment completed.'),
+ 'PAYMENT.SALE.DENIED': _('Payment denied.'),
+ 'PAYMENT.SALE.REFUNDED': _('Payment refunded.'),
+ 'PAYMENT.SALE.REVERSED': _('Payment reversed.'),
+ 'PAYMENT.SALE.PENDING': _('Payment pending.'),
+ 'CHECKOUT.ORDER.APPROVED': _('Order approved.'),
+ }
+
+ if event_type in plains:
+ text = plains[event_type]
+ else:
+ text = event_type
+
+ if text:
+ return _('PayPal reported an event: {}').format(text)
+
+
+@receiver(register_global_settings, dispatch_uid='paypal2_global_settings')
+def register_global_settings(sender, **kwargs):
+ return OrderedDict([
+ ('payment_paypal_connect_client_id', forms.CharField(
+ label=_('PayPal ISU/Connect: Client ID'),
+ required=False,
+ )),
+ ('payment_paypal_connect_secret_key', SecretKeySettingsField(
+ label=_('PayPal ISU/Connect: Secret key'),
+ required=False,
+ )),
+ ('payment_paypal_connect_partner_merchant_id', forms.CharField(
+ label=_('PayPal ISU/Connect: Partner Merchant ID'),
+ help_text=_('This is not the BN-code, but rather the ID of the merchant account which holds branding information for ISU.'),
+ required=False,
+ )),
+ ('payment_paypal_connect_endpoint', forms.ChoiceField(
+ label=_('PayPal ISU/Connect Endpoint'),
+ initial='live',
+ choices=(
+ ('live', 'Live'),
+ ('sandbox', 'Sandbox'),
+ ),
+ )),
+ ])
+
+
+@receiver(html_head, dispatch_uid="payment_paypal2_html_head")
+def html_head_presale(sender, request=None, **kwargs):
+ provider = PaypalMethod(sender)
+ url = resolve(request.path_info)
+
+ if provider.settings.get('_enabled', as_type=bool) and (
+ url.url_name == "event.order.pay.change" or
+ (url.url_name == "event.checkout" and url.kwargs['step'] == "payment") or
+ (url.namespace == "plugins:paypal2" and url.url_name == "pay")
+ ):
+ provider.init_api()
+ template = get_template('pretixplugins/paypal2/presale_head.html')
+
+ ctx = {
+ 'client_id': provider.client.environment.client_id,
+ 'merchant_id': provider.client.environment.merchant_id or '',
+ 'csp_nonce': _nonce(request),
+ 'debug': settings.DEBUG,
+ 'settings': provider.settings,
+ # If we ever have more APMs that can be disabled, we should iterate over the
+ # disable_method_*/enable_method*-keys
+ 'disable_funding': 'sepa' if provider.settings.get('disable_method_sepa', as_type=bool) else '',
+ 'enable_funding': 'paylater' if provider.settings.get('enable_method_paylater', as_type=bool) else ''
+ }
+
+ return template.render(ctx)
+ else:
+ return ""
+
+
+@receiver(signal=process_response, dispatch_uid="payment_paypal2_middleware_resp")
+def signal_process_response(sender, request: HttpRequest, response: HttpResponse, **kwargs):
+ provider = PaypalMethod(sender)
+ url = resolve(request.path_info)
+
+ if provider.settings.get('_enabled', as_type=bool) and (
+ url.url_name == "event.order.pay.change" or
+ (url.url_name == "event.checkout" and url.kwargs['step'] == "payment") or
+ (url.namespace == "plugins:paypal2" and url.url_name == "pay")
+ ):
+ if 'Content-Security-Policy' in response:
+ h = _parse_csp(response['Content-Security-Policy'])
+ else:
+ h = {}
+
+ csps = {
+ 'script-src': ['https://www.paypal.com', "'nonce-{}'".format(_nonce(request))],
+ 'frame-src': ['https://www.paypal.com', 'https://www.sandbox.paypal.com', "'nonce-{}'".format(_nonce(request))],
+ 'connect-src': ['https://www.paypal.com', 'https://www.sandbox.paypal.com'], # Or not - seems to only affect PayPal logging...
+ 'style-src': ["'nonce-{}'".format(_nonce(request))]
+ }
+
+ _merge_csp(h, csps)
+
+ if h:
+ response['Content-Security-Policy'] = _render_csp(h)
+
+ return response
+
+
+settings_hierarkey.add_default('payment_paypal_debug_buyer_country', '', str)
+settings_hierarkey.add_default('payment_paypal_method_wallet', True, bool)
+
+
+def _nonce(request):
+ if not hasattr(request, "_paypal_nonce"):
+ request._paypal_nonce = get_random_string(32)
+ return request._paypal_nonce
diff --git a/src/pretix/plugins/paypal2/static/pretixplugins/paypal2/paypal_logo.svg b/src/pretix/plugins/paypal2/static/pretixplugins/paypal2/paypal_logo.svg
new file mode 100644
index 0000000000..a04e116dc6
--- /dev/null
+++ b/src/pretix/plugins/paypal2/static/pretixplugins/paypal2/paypal_logo.svg
@@ -0,0 +1,15 @@
+
+
+
+
\ No newline at end of file
diff --git a/src/pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js b/src/pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js
new file mode 100644
index 0000000000..458345eb33
--- /dev/null
+++ b/src/pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js
@@ -0,0 +1,281 @@
+/*global $, paypal_client_id, paypal_loadingmessage, gettext */
+'use strict';
+
+var pretixpaypal = {
+ paypal: null,
+ client_id: null,
+ order_id: null,
+ payer_id: null,
+ merchant_id: null,
+ currency: null,
+ method: null,
+ additional_disabled_funding: null,
+ additional_enabled_funding: null,
+ debug_buyer_country: null,
+ continue_button: null,
+ paypage: false,
+ method_map: {
+ wallet: {
+ method: 'wallet',
+ funding_source: 'paypal',
+ //disable_funding: null,
+ //enable_funding: 'paylater',
+ early_auth: true,
+ },
+ apm: {
+ method: 'apm',
+ funding_source: null,
+ //disable_funding: null,
+ //enable_funding: null,
+ early_auth: false,
+ }
+ },
+ apm_map: {
+ paypal: gettext('PayPal'),
+ venmo: gettext('Venmo'),
+ applepay: gettext('Apple Pay'),
+ itau: gettext('Itaú'),
+ credit: gettext('PayPal Credit'),
+ card: gettext('Credit Card'),
+ paylater: gettext('PayPal Pay Later'),
+ ideal: gettext('iDEAL'),
+ sepa: gettext('SEPA Direct Debit'),
+ bancontact: gettext('Bancontact'),
+ giropay: gettext('giropay'),
+ sofort: gettext('SOFORT'),
+ eps: gettext('eps'),
+ mybank: gettext('MyBank'),
+ p24: gettext('Przelewy24'),
+ verkkopankki: gettext('Verkkopankki'),
+ payu: gettext('PayU'),
+ blik: gettext('BLIK'),
+ trustly: gettext('Trustly'),
+ zimpler: gettext('Zimpler'),
+ maxima: gettext('Maxima'),
+ oxxo: gettext('OXXO'),
+ boleto: gettext('Boleto'),
+ wechatpay: gettext('WeChat Pay'),
+ mercadopago: gettext('Mercado Pago')
+ },
+
+ load: function () {
+ if (pretixpaypal.paypal === null) {
+ pretixpaypal.client_id = $.trim($("#paypal_client_id").html());
+ pretixpaypal.merchant_id = $.trim($("#paypal_merchant_id").html());
+ pretixpaypal.debug_buyer_country = $.trim($("#paypal_buyer_country").html());
+ pretixpaypal.continue_button = $('.checkout-button-row').closest("form").find(".checkout-button-row .btn-primary");
+ pretixpaypal.continue_button.closest('div').append('');
+ pretixpaypal.additional_disabled_funding = $.trim($("#paypal_disable_funding").html());
+ pretixpaypal.additional_enabled_funding = $.trim($("#paypal_enable_funding").html());
+ pretixpaypal.paypage = Boolean($('#paypal-button-container').data('paypage'));
+ pretixpaypal.order_id = $.trim($("#paypal_oid").html());
+ pretixpaypal.currency = $("body").attr("data-currency");
+ }
+
+ pretixpaypal.continue_button.prop("disabled", true);
+
+ // We are setting the cogwheel already here, as the renderAPM() method might take some time to get loaded.
+ let apmtextselector = $("label[for=input_payment_paypal_apm]");
+ apmtextselector.prepend(' ');
+
+ let sdk_url = 'https://www.paypal.com/sdk/js' +
+ '?client-id=' + pretixpaypal.client_id +
+ '&components=buttons,funding-eligibility' +
+ '¤cy=' + pretixpaypal.currency;
+
+ if (pretixpaypal.merchant_id) {
+ sdk_url += '&merchant-id=' + pretixpaypal.merchant_id;
+ }
+
+ if (pretixpaypal.additional_disabled_funding) {
+ sdk_url += '&disable-funding=' + [pretixpaypal.additional_disabled_funding].filter(Boolean).join(',');
+ }
+
+ if (pretixpaypal.additional_enabled_funding) {
+ sdk_url += '&enable-funding=' + [pretixpaypal.additional_enabled_funding].filter(Boolean).join(',');
+ }
+
+ if (pretixpaypal.debug_buyer_country) {
+ sdk_url += '&buyer-country=' + pretixpaypal.debug_buyer_country;
+ }
+
+ let ppscript = document.createElement('script');
+ let ready = false;
+ let head = document.getElementsByTagName("head")[0];
+ ppscript.setAttribute('src', sdk_url);
+ ppscript.setAttribute('data-csp-nonce', $.trim($("#csp_nonce").html()));
+ ppscript.setAttribute('data-page-type', 'checkout');
+ ppscript.setAttribute('data-partner-attribution-id', 'ramiioGmbH_Cart_PPCP');
+ document.head.appendChild(ppscript);
+
+ ppscript.onload = ppscript.onreadystatechange = function () {
+ if (!ready && (!this.readyState || this.readyState === "loaded" || this.readyState === "complete")) {
+ ready = true;
+
+ pretixpaypal.paypal = paypal;
+
+ // Handle memory leak in IE
+ ppscript.onload = ppscript.onreadystatechange = null;
+ if (head && ppscript.parentNode) {
+ head.removeChild(ppscript);
+ }
+ }
+ };
+ },
+
+ ready: function () {
+ if ($("input[name=payment][value=paypal_apm]").length > 0) {
+ pretixpaypal.renderAPMs();
+ }
+
+ $("input[name=payment][value^='paypal']").change(function () {
+ pretixpaypal.renderButton($(this).val());
+ });
+
+ $("input[name=payment]").not("[value^='paypal']").change(function () {
+ pretixpaypal.restore();
+ });
+
+ if ($("input[name=payment][value^='paypal']").is(':checked') || $(".payment-redo-form").length) {
+ pretixpaypal.renderButton($("input[name=payment][value^='paypal']:checked").val());
+ }
+
+ if ($('#paypal-button-container').data('paypage')) {
+ pretixpaypal.renderButton('paypal_apm');
+ }
+ },
+
+ restore: function () {
+ // if PayPal has not been initialized, there shouldn't be anything to cleanup
+ if (pretixpaypal.paypal !== null) {
+ $('#paypal-button-container').empty()
+ pretixpaypal.continue_button.text(gettext('Continue'));
+ pretixpaypal.continue_button.show();
+ pretixpaypal.continue_button.prop("disabled", false);
+ }
+ },
+
+ renderButton: function (method) {
+ if (method === 'paypal') {
+ method = "wallet"
+ } else {
+ method = method.split('paypal_').at(-1)
+ }
+ pretixpaypal.method = pretixpaypal.method_map[method];
+
+ if (pretixpaypal.method.method === 'apm' && !pretixpaypal.paypage) {
+ pretixpaypal.restore();
+ return;
+ }
+
+ $('#paypal-button-container').empty()
+ $('#paypal-card-container').empty()
+
+ let button = pretixpaypal.paypal.Buttons({
+ fundingSource: pretixpaypal.method.funding_source,
+ style: {
+ layout: pretixpaypal.method.early_auth ? 'horizontal' : 'vertical',
+ //color: 'white',
+ shape: 'rect',
+ label: 'pay',
+ tagline: false
+ },
+ createOrder: function (data, actions) {
+ if (pretixpaypal.order_id) {
+ return pretixpaypal.order_id;
+ }
+
+ // On the paypal:pay view, we already pregenerated the OID.
+ // Since this view is also only used for APMs, we only need the XHR-calls for the Smart Payment Buttons.
+ if (pretixpaypal.paypage) {
+ return $("#payment_paypal_" + pretixpaypal.method.method + "_oid");
+ } else {
+ var xhrurl = $("#payment_paypal_" + pretixpaypal.method.method + "_xhr").val();
+ }
+
+ return fetch(xhrurl, {
+ method: 'POST'
+ }).then(function (res) {
+ return res.json();
+ }).then(function (data) {
+ return data.id;
+ });
+ },
+ onApprove: function (data, actions) {
+ waitingDialog.show(gettext("Confirming your payment …"));
+ pretixpaypal.order_id = data.orderID;
+ pretixpaypal.payer_id = data.payerID;
+
+ let method = pretixpaypal.paypage ? "wallet" : pretixpaypal.method.method;
+ let selectorstub = "#payment_paypal_" + method;
+ var $form = $(selectorstub + "_oid").closest("form");
+ // Insert the tokens into the form so it gets submitted to the server
+ $(selectorstub + "_oid").val(pretixpaypal.order_id);
+ $(selectorstub + "_payer").val(pretixpaypal.payer_id);
+ // and submit
+ $form.get(0).submit();
+
+ // billingToken: null
+ // facilitatorAccessToken: "A21AAL_fEu0gDD-sIXyOy65a6MjgSJJrhmxuPcxxUGnL5gW2DzTxiiAksfoC4x8hD-BjeY1LsFVKl7ceuO7UR1a9pQr8Q_AVw"
+ // orderID: "7RF70259NY7589848"
+ // payerID: "8M3BU92Z97VXA"
+ // paymentID: null
+ },
+ });
+
+ if (button.isEligible()) {
+ button.render('#paypal-button-container');
+ pretixpaypal.continue_button.hide();
+ } else {
+ pretixpaypal.continue_button.text(gettext('Payment method unavailable'));
+ pretixpaypal.continue_button.show();
+ }
+ },
+
+ renderAPMs: function () {
+ pretixpaypal.restore();
+ let inputselector = $("input[name=payment][value=paypal_apm]");
+ // The first selector is used on the regular payment-step of the checkout flow
+ // The second selector is used for the payment method change view.
+ // In the long run, the layout of both pages should be adjusted to be one.
+ let textselector = $("label[for=input_payment_paypal_apm]");
+ let textselector2 = inputselector.next("strong");
+ let eligibles = [];
+
+ pretixpaypal.paypal.getFundingSources().forEach(function (fundingSource) {
+ // Let's always skip PayPal, since it's always a dedicated funding source
+ if (fundingSource === 'paypal') {
+ return;
+ }
+
+ // This could also be paypal.Marks() - but they only expose images instead of cleartext...
+ let button = pretixpaypal.paypal.Buttons({
+ fundingSource: fundingSource
+ });
+
+ if (button.isEligible()) {
+ eligibles.push(gettext(pretixpaypal.apm_map[fundingSource] || fundingSource));
+ }
+ });
+
+ inputselector.attr('title', eligibles.join(', '));
+ textselector.fadeOut(300, function () {
+ textselector.text(eligibles.join(', '));
+ textselector.fadeIn(300);
+ });
+ textselector2.fadeOut(300, function () {
+ textselector2[0].textContent = eligibles.join(', ');
+ textselector2.fadeIn(300);
+ });
+ }
+};
+
+$(function () {
+ pretixpaypal.load();
+
+ (async() => {
+ while(!pretixpaypal.paypal)
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ pretixpaypal.ready();
+ })();
+});
diff --git a/src/pretix/plugins/paypal2/templates/pretixplugins/paypal2/checkout_payment_confirm.html b/src/pretix/plugins/paypal2/templates/pretixplugins/paypal2/checkout_payment_confirm.html
new file mode 100644
index 0000000000..418f881a9f
--- /dev/null
+++ b/src/pretix/plugins/paypal2/templates/pretixplugins/paypal2/checkout_payment_confirm.html
@@ -0,0 +1,14 @@
+{% load i18n %}
+
+
+ {% if method == "wallet" %}
+ {% blocktrans trimmed %}
+ The total amount listed above will be withdrawn from your PayPal account after the
+ confirmation of your purchase.
+ {% endblocktrans %}
+ {% else %}
+ {% blocktrans trimmed %}
+ After placing your order, you will be able to select your desired payment method, including PayPal.
+ {% endblocktrans %}
+ {% endif %}
+
+ {% if method == "wallet" %}
+ {% blocktrans trimmed %}
+ Please click the "Pay with PayPal" button below to start your payment.
+ {% endblocktrans %}
+ {% else %}
+ {% blocktrans trimmed %}
+ After you clicked continue, we will redirect you to PayPal to fill in your payment
+ details. You will then be redirected back here to review and confirm your order.
+ {% endblocktrans %}
+ {% endif %}
+
+
+
+
+
\ No newline at end of file
diff --git a/src/pretix/plugins/paypal2/templates/pretixplugins/paypal2/control.html b/src/pretix/plugins/paypal2/templates/pretixplugins/paypal2/control.html
new file mode 100644
index 0000000000..7cf80db0d2
--- /dev/null
+++ b/src/pretix/plugins/paypal2/templates/pretixplugins/paypal2/control.html
@@ -0,0 +1,18 @@
+{% load i18n %}
+
+{% if payment_info %}
+
{% blocktrans trimmed %}
+ Our attempt to execute your Payment via PayPal has failed. Please try again or contact us.
+ {% endblocktrans %}
+{% else %}
+
{% blocktrans trimmed %}
+ We're waiting for an answer from PayPal regarding your payment. Please contact us, if this
+ takes more than a few hours.
+ {% endblocktrans %}