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 += ( - '

%s ' - '%s' - '
' - ) % ( - _("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 += ( + '

%s ' + '%s' + '
' + ) % ( + _("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 %} +

diff --git a/src/pretix/plugins/paypal2/templates/pretixplugins/paypal2/checkout_payment_form.html b/src/pretix/plugins/paypal2/templates/pretixplugins/paypal2/checkout_payment_form.html new file mode 100644 index 0000000000..6bdb7d82f4 --- /dev/null +++ b/src/pretix/plugins/paypal2/templates/pretixplugins/paypal2/checkout_payment_form.html @@ -0,0 +1,18 @@ +{% load i18n %} + +

+ {% 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 %} +
+
{% trans "Order ID" %}
+
{{ payment_info.id }}
+
{% trans "Status" %}
+
{{ payment_info.status }}
+
{% trans "Payer" %}
+
{{ payment_info.payer.email_address }}
+
{% trans "Last update" %}
+
{{ payment_info.purchase_units.0.payments.captures.0.update_time }}
+
{% trans "Total value" %}
+
{{ payment_info.purchase_units.0.payments.captures.0.amount.value }}
+
{% trans "Currency" %}
+
{{ payment_info.purchase_units.0.payments.captures.0.amount.currency_code }}
+
+{% endif %} diff --git a/src/pretix/plugins/paypal2/templates/pretixplugins/paypal2/control_legacy.html b/src/pretix/plugins/paypal2/templates/pretixplugins/paypal2/control_legacy.html new file mode 100644 index 0000000000..165c0c9172 --- /dev/null +++ b/src/pretix/plugins/paypal2/templates/pretixplugins/paypal2/control_legacy.html @@ -0,0 +1,18 @@ +{% load i18n %} + +{% if payment_info %} +
+
{% trans "Payment ID" %}
+
{{ payment_info.id }}
+
{% trans "Sale ID" %}
+
{{ sale_id|default_if_none:"?" }}
+
{% trans "Payer" %}
+
{{ payment_info.payer.payer_info.email }}
+
{% trans "Last update" %}
+
{{ payment_info.update_time }}
+
{% trans "Total value" %}
+
{{ payment_info.transactions.0.amount.total }}
+
{% trans "Currency" %}
+
{{ payment_info.transactions.0.amount.currency }}
+
+{% endif %} diff --git a/src/pretix/plugins/paypal2/templates/pretixplugins/paypal2/pay.html b/src/pretix/plugins/paypal2/templates/pretixplugins/paypal2/pay.html new file mode 100644 index 0000000000..72d72293da --- /dev/null +++ b/src/pretix/plugins/paypal2/templates/pretixplugins/paypal2/pay.html @@ -0,0 +1,45 @@ +{% extends "pretixpresale/event/base.html" %} +{% load i18n %} +{% load eventurl %} +{% load static %} +{% block title %}{% trans "Pay order" %}{% endblock %} +{% block custom_header %} + {{ block.super }} + {% if oid %} + + {% endif %} +{% endblock %} +{% block content %} +
+
+

+ {% blocktrans trimmed with code=order.code %} + Pay order: {{ code }} + {% endblocktrans %} +

+
+
+
+ {% csrf_token %} + +

{% trans "Please use the button/form below to complete your payment." %}

+
+ + +
+
+
+ +{% endblock %} diff --git a/src/pretix/plugins/paypal2/templates/pretixplugins/paypal2/pending.html b/src/pretix/plugins/paypal2/templates/pretixplugins/paypal2/pending.html new file mode 100644 index 0000000000..7ea6214ea9 --- /dev/null +++ b/src/pretix/plugins/paypal2/templates/pretixplugins/paypal2/pending.html @@ -0,0 +1,12 @@ +{% load i18n %} + +{% if retry %} +

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

+{% endif %} diff --git a/src/pretix/plugins/paypal2/templates/pretixplugins/paypal2/presale_head.html b/src/pretix/plugins/paypal2/templates/pretixplugins/paypal2/presale_head.html new file mode 100644 index 0000000000..35ea3046d6 --- /dev/null +++ b/src/pretix/plugins/paypal2/templates/pretixplugins/paypal2/presale_head.html @@ -0,0 +1,20 @@ +{% load static %} +{% load compress %} +{% load i18n %} + +{% compress js %} + +{% endcompress %} + + + + +{% if disable_funding %} + +{% endif %} +{% if enable_funding %} + +{% endif %} +{% if debug %} + +{% endif %} \ No newline at end of file diff --git a/src/pretix/plugins/paypal2/templates/pretixplugins/paypal2/redirect.html b/src/pretix/plugins/paypal2/templates/pretixplugins/paypal2/redirect.html new file mode 100644 index 0000000000..2b7ff2984d --- /dev/null +++ b/src/pretix/plugins/paypal2/templates/pretixplugins/paypal2/redirect.html @@ -0,0 +1,32 @@ +{% load compress %} +{% load i18n %} +{% load static %} + + + + {{ settings.PRETIX_INSTANCE_NAME }} + {% compress css %} + + {% endcompress %} + {% compress js %} + + {% endcompress %} + + +
+

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

+ +

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

+

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

+ +
+ + diff --git a/src/pretix/plugins/paypal2/urls.py b/src/pretix/plugins/paypal2/urls.py new file mode 100644 index 0000000000..e49ca964ae --- /dev/null +++ b/src/pretix/plugins/paypal2/urls.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.conf.urls import include, re_path + +from .views import ( + PayView, XHRView, abort, isu_disconnect, isu_return, redirect_view, + success, webhook, +) + +event_patterns = [ + re_path(r'^paypal2/', include([ + re_path(r'^abort/$', abort, name='abort'), + re_path(r'^return/$', success, name='return'), + re_path(r'^redirect/$', redirect_view, name='redirect'), + re_path(r'^xhr/$', XHRView.as_view(), name='xhr'), + re_path(r'^pay/(?P[^/]+)/(?P[^/]+)/(?P[^/]+)/$', PayView.as_view(), name='pay'), + re_path(r'^(?P[^/][^w]+)/(?P[A-Za-z0-9]+)/xhr/$', XHRView.as_view(), name='xhr'), + + 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'), + re_path(r'w/(?P[a-zA-Z0-9]{16})/xhr/', XHRView.as_view(), name='xhr'), + ])), +] + +urlpatterns = [ + re_path(r'^control/event/(?P[^/]+)/(?P[^/]+)/paypal2/disconnect/', isu_disconnect, + name='isu.disconnect'), + re_path(r'^control/event/(?P[^/]+)/(?P[^/]+)/paypal/return/$', isu_return, name='isu.return'), + re_path(r'^_paypal/webhook/$', webhook, name='webhook'), +] diff --git a/src/pretix/plugins/paypal2/views.py b/src/pretix/plugins/paypal2/views.py new file mode 100644 index 0000000000..42842eb24f --- /dev/null +++ b/src/pretix/plugins/paypal2/views.py @@ -0,0 +1,461 @@ +# +# 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 +# . +# + +# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of +# the Apache License 2.0 can be obtained at . +# +# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A +# full history of changes and contributors is available at . +# +# This file contains Apache-licensed contributions copyrighted by: Flavia Bastos +# +# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under the License. +import hashlib +import json +import logging +from decimal import Decimal + +from django.contrib import messages +from django.core import signing +from django.db.models import Sum +from django.http import ( + Http404, HttpResponse, HttpResponseBadRequest, JsonResponse, +) +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse +from django.utils.decorators import method_decorator +from django.utils.functional import cached_property +from django.utils.translation import gettext_lazy as _ +from django.views.decorators.clickjacking import xframe_options_exempt +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_POST +from django.views.generic import TemplateView +from django_scopes import scopes_disabled +from paypalcheckoutsdk import orders as pp_orders, payments as pp_payments + +from pretix.base.models import Event, Order, OrderPayment, OrderRefund, Quota +from pretix.base.payment import PaymentException +from pretix.base.settings import GlobalSettingsObject +from pretix.control.permissions import event_permission_required +from pretix.multidomain.urlreverse import eventreverse +from pretix.plugins.paypal2.client.customer.partners_merchantintegrations_get_request import ( + PartnersMerchantIntegrationsGetRequest, +) +from pretix.plugins.paypal2.payment import PaypalMethod, PaypalMethod as Paypal +from pretix.plugins.paypal.models import ReferencedPayPalObject +from pretix.presale.views import get_cart, get_cart_total + +logger = logging.getLogger('pretix.plugins.paypal2') + + +class PaypalOrderView: + def dispatch(self, request, *args, **kwargs): + try: + self.order = request.event.orders.get(code=kwargs['order']) + if hashlib.sha1(self.order.secret.lower().encode()).hexdigest() != kwargs['hash'].lower(): + raise Http404('Unknown order') + except Order.DoesNotExist: + # Do a hash comparison as well to harden timing attacks + if 'abcdefghijklmnopq'.lower() == hashlib.sha1('abcdefghijklmnopq'.encode()).hexdigest(): + raise Http404('Unknown order') + else: + raise Http404('Unknown order') + return super().dispatch(request, *args, **kwargs) + + @cached_property + def payment(self): + return get_object_or_404( + self.order.payments, + pk=self.kwargs['payment'], + provider__istartswith='paypal', + ) + + def _redirect_to_order(self): + return redirect(eventreverse(self.request.event, 'presale:event.order', kwargs={ + 'order': self.order.code, + 'secret': self.order.secret + }) + ('?paid=yes' if self.order.status == Order.STATUS_PAID else '')) + + +@xframe_options_exempt +def redirect_view(request, *args, **kwargs): + signer = signing.Signer(salt='safe-redirect') + try: + url = signer.unsign(request.GET.get('url', '')) + except signing.BadSignature: + return HttpResponseBadRequest('Invalid parameter') + + r = render(request, 'pretixplugins/paypal2/redirect.html', { + 'url': url, + }) + r._csp_ignore = True + return r + + +@method_decorator(csrf_exempt, name='dispatch') +@method_decorator(xframe_options_exempt, 'dispatch') +class XHRView(TemplateView): + template_name = '' + + def post(self, request, *args, **kwargs): + if 'order' in self.kwargs: + order = self.request.event.orders.filter(code=self.kwargs['order']).select_related('event').first() + if order: + if order.secret.lower() == self.kwargs['secret'].lower(): + pass + else: + order = None + else: + order = None + + prov = PaypalMethod(request.event) + + if order: + lp = order.payments.last() + if lp and lp.state not in (OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED): + fee = lp.fee.value - prov.calculate_fee(order.pending_sum - lp.fee.value) + else: + fee = prov.calculate_fee(order.pending_sum) + + cart = { + 'positions': order.positions, + 'total': order.pending_sum, + 'fee': fee, + } + else: + cart = { + 'positions': get_cart(request), + 'total': get_cart_total(request), + 'fee': prov.calculate_fee(get_cart_total(request)), + } + + paypal_order = prov._create_paypal_order(request, None, cart) + r = JsonResponse(paypal_order.dict()) + r._csp_ignore = True + return r + + +@method_decorator(xframe_options_exempt, 'dispatch') +class PayView(PaypalOrderView, TemplateView): + template_name = '' + + def get(self, request, *args, **kwargs): + if self.payment.state != OrderPayment.PAYMENT_STATE_CREATED: + return self._redirect_to_order() + else: + r = render(request, 'pretixplugins/paypal2/pay.html', self.get_context_data()) + return r + + def post(self, request, *args, **kwargs): + self.payment.payment_provider.execute_payment(request, self.payment) + return self._redirect_to_order() + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + + ctx['order'] = self.order + ctx['oid'] = self.payment.info_data['id'] + ctx['method'] = self.payment.payment_provider.method + return ctx + + +@scopes_disabled() +@event_permission_required('can_change_event_settings') +def isu_return(request, *args, **kwargs): + getparams = ['merchantId', 'merchantIdInPayPal', 'permissionsGranted', 'accountStatus', 'consentStatus', 'productIntentID', 'isEmailConfirmed'] + sessionparams = ['payment_paypal_isu_event', 'payment_paypal_isu_tracking_id'] + if not any(k in request.GET for k in getparams) or not any(k in request.session for k in sessionparams): + 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_isu_event']) + + gs = GlobalSettingsObject() + prov = Paypal(event) + prov.init_api() + + try: + req = PartnersMerchantIntegrationsGetRequest( + gs.settings.get('payment_paypal_connect_partner_merchant_id'), + request.GET.get('merchantIdInPayPal') + ) + response = prov.client.execute(req) + except IOError as e: + messages.error(request, _('An error occurred during connecting with PayPal, please try again.')) + logger.exception('PayPal PartnersMerchantIntegrationsGetRequest: {}'.format(str(e))) + else: + params = ['merchant_id', 'tracking_id', 'payments_receivable', 'primary_email_confirmed'] + if not any(k in response.result for k in params): + if 'message' in response.result: + messages.error(request, response.result.message) + else: + messages.error(request, _('An error occurred during connecting with PayPal, please try again.')) + else: + if response.result.tracking_id != request.session['payment_paypal_isu_tracking_id']: + messages.error(request, _('An error occurred during connecting with PayPal, please try again.')) + else: + if request.GET.get("isEmailConfirmed") == "false": # Yes - literal! + messages.warning( + request, + _('The e-mail address on your PayPal account has not yet been confirmed. You will need to do ' + 'this before you can start accepting payments.') + ) + messages.success( + request, + _('Your PayPal account is now connected to pretix. You can change the settings in detail below.') + ) + + event.settings.payment_paypal_isu_merchant_id = response.result.merchant_id + + # Just for good measure: Let's keep a copy of the granted scopes + for integration in response.result.oauth_integrations: + if integration.integration_type == 'OAUTH_THIRD_PARTY': + for third_party in integration.oauth_third_party: + if third_party.partner_client_id == prov.client.environment.client_id: + event.settings.payment_paypal_isu_scopes = third_party.scopes + + return redirect(reverse('control:event.settings.payment.provider', kwargs={ + 'organizer': event.organizer.slug, + 'event': event.slug, + 'provider': 'paypal_settings' + })) + + +def success(request, *args, **kwargs): + token = request.GET.get('token') + payer = request.GET.get('PayerID') + request.session['payment_paypal_token'] = token + request.session['payment_paypal_payer'] = payer + + urlkwargs = {} + if 'cart_namespace' in kwargs: + urlkwargs['cart_namespace'] = kwargs['cart_namespace'] + + if request.session.get('payment_paypal_payment'): + payment = OrderPayment.objects.get(pk=request.session.get('payment_paypal_payment')) + else: + payment = None + + if request.session.get('payment_paypal_id', None): + if payment: + prov = Paypal(request.event) + try: + resp = prov.execute_payment(request, payment) + except PaymentException as e: + messages.error(request, str(e)) + urlkwargs['step'] = 'payment' + return redirect(eventreverse(request.event, 'presale:event.checkout', kwargs=urlkwargs)) + if resp: + return resp + else: + messages.error(request, _('Invalid response from PayPal received.')) + logger.error('Session did not contain payment_paypal_id') + urlkwargs['step'] = 'payment' + return redirect(eventreverse(request.event, 'presale:event.checkout', kwargs=urlkwargs)) + + if payment: + return redirect(eventreverse(request.event, 'presale:event.order', kwargs={ + 'order': payment.order.code, + 'secret': payment.order.secret + }) + ('?paid=yes' if payment.order.status == Order.STATUS_PAID else '')) + else: + urlkwargs['step'] = 'confirm' + return redirect(eventreverse(request.event, 'presale:event.checkout', kwargs=urlkwargs)) + + +def abort(request, *args, **kwargs): + messages.error(request, _('It looks like you canceled the PayPal payment')) + + if request.session.get('payment_paypal_payment'): + payment = OrderPayment.objects.get(pk=request.session.get('payment_paypal_payment')) + else: + payment = None + + if payment: + return redirect(eventreverse(request.event, 'presale:event.order', kwargs={ + 'order': payment.order.code, + 'secret': payment.order.secret + }) + ('?paid=yes' if payment.order.status == Order.STATUS_PAID else '')) + else: + return redirect(eventreverse(request.event, 'presale:event.checkout', kwargs={'step': 'payment'})) + + +@csrf_exempt +@require_POST +@scopes_disabled() +def webhook(request, *args, **kwargs): + event_body = request.body.decode('utf-8').strip() + event_json = json.loads(event_body) + + # V1/V2 Sorting -- Start + if 'event_type' not in event_json: + return HttpResponse("Invalid body, no event_type given", status=400) + + if event_json['event_type'].startswith('PAYMENT.SALE.'): + from pretix.plugins.paypal.views import webhook + return webhook(request, *args, **kwargs) + # V1/V2 Sorting -- End + + # We do not check the signature, we just use it as a trigger to look the charge up. + if 'resource_type' not in event_json: + return HttpResponse("Invalid body, no resource_type given", status=400) + + if event_json['resource_type'] not in ["checkout-order", "refund", "capture"]: + return HttpResponse("Not interested in this resource type", status=200) + + # Retrieve the Charge ID of the refunded payment + if event_json['resource_type'] == 'refund': + payloadid = get_link(event_json['resource']['links'], 'up')['href'].split('/')[-1] + else: + payloadid = event_json['resource']['id'] + + refs = [payloadid] + if event_json['resource'].get('supplementary_data', {}).get('related_ids', {}).get('order_id'): + refs.append(event_json['resource'].get('supplementary_data').get('related_ids').get('order_id')) + + rso = ReferencedPayPalObject.objects.select_related('order', 'order__event').filter( + reference__in=refs + ).first() + if rso: + event = rso.order.event + else: + rso = None + if hasattr(request, 'event'): + event = request.event + else: + return HttpResponse("Unable to detect event", status=200) + + prov = Paypal(event) + prov.init_api() + + try: + if rso: + payloadid = rso.payment.info_data['id'] + sale = prov.client.execute(pp_orders.OrdersGetRequest(payloadid)).result + except IOError: + logger.exception('PayPal error on webhook. Event data: %s' % str(event_json)) + return HttpResponse('Sale not found', status=500) + + if rso and rso.payment: + payment = rso.payment + else: + payments = OrderPayment.objects.filter(order__event=event, provider='paypal', + info__icontains=sale['id']) + payment = None + for p in payments: + # Legacy PayPal info-data + if "purchase_units" not in p.info_data: + try: + req = pp_orders.OrdersGetRequest(p.info_data['cart']) + response = prov.client.execute(req) + p.info = json.dumps(response.result.dict()) + p.save(update_fields=['info']) + p.refresh_from_db() + except IOError: + logger.exception('PayPal error on webhook. Event data: %s' % str(event_json)) + return HttpResponse('Could not retrieve Order Data', status=500) + + for res in p.info_data['purchase_units'][0]['payments']['captures']: + if res['status'] in ['COMPLETED', 'PARTIALLY_REFUNDED'] and res['id'] == sale['id']: + payment = p + break + + if not payment: + return HttpResponse('Payment not found', status=200) + + payment.order.log_action('pretix.plugins.paypal.event', data=event_json) + + if payment.state == OrderPayment.PAYMENT_STATE_CONFIRMED and sale['status'] in ('PARTIALLY_REFUNDED', 'REFUNDED', 'COMPLETED'): + if event_json['resource_type'] == 'refund': + try: + req = pp_payments.RefundsGetRequest(event_json['resource']['id']) + refund = prov.client.execute(req).result + except IOError: + logger.exception('PayPal error on webhook. Event data: %s' % str(event_json)) + return HttpResponse('Refund not found', status=500) + + known_refunds = {r.info_data.get('id'): r for r in payment.refunds.all()} + if refund['id'] not in known_refunds: + payment.create_external_refund( + amount=abs(Decimal(refund['amount']['value'])), + info=json.dumps(refund.dict() if not isinstance(refund, dict) else refund) + ) + elif known_refunds.get(refund['id']).state in ( + OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT) and refund['status'] == 'COMPLETED': + known_refunds.get(refund['id']).done() + + if 'seller_payable_breakdown' in refund and 'total_refunded_amount' in refund['seller_payable_breakdown']: + known_sum = payment.refunds.filter( + state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT, + OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_SOURCE_EXTERNAL) + ).aggregate(s=Sum('amount'))['s'] or Decimal('0.00') + total_refunded_amount = Decimal(refund['seller_payable_breakdown']['total_refunded_amount']['value']) + if known_sum < total_refunded_amount: + payment.create_external_refund( + amount=total_refunded_amount - known_sum + ) + elif sale['status'] == 'REFUNDED': + known_sum = payment.refunds.filter( + state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT, + OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_SOURCE_EXTERNAL) + ).aggregate(s=Sum('amount'))['s'] or Decimal('0.00') + + if known_sum < payment.amount: + payment.create_external_refund( + amount=payment.amount - known_sum + ) + elif payment.state in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED, + OrderPayment.PAYMENT_STATE_CANCELED, OrderPayment.PAYMENT_STATE_FAILED) \ + and sale['status'] == 'COMPLETED': + try: + payment.confirm() + except Quota.QuotaExceededException: + pass + + return HttpResponse(status=200) + + +@event_permission_required('can_change_event_settings') +@require_POST +def isu_disconnect(request, **kwargs): + del request.event.settings.payment_paypal_connect_refresh_token + del request.event.settings.payment_paypal_connect_user_id + del request.event.settings.payment_paypal_isu_merchant_id + del request.event.settings.payment_paypal_isu_scopes + request.event.settings.payment_paypal__enabled = False + messages.success(request, _('Your PayPal account has been disconnected.BB')) + + return redirect(reverse('control:event.settings.payment.provider', kwargs={ + 'organizer': request.event.organizer.slug, + 'event': request.event.slug, + 'provider': 'paypal_settings' + })) + + +def get_link(links, rel): + for link in links: + if link['rel'] == rel: + return link + + return None diff --git a/src/pretix/settings.py b/src/pretix/settings.py index 2521770627..ade511d1b5 100644 --- a/src/pretix/settings.py +++ b/src/pretix/settings.py @@ -356,6 +356,7 @@ INSTALLED_APPS = [ 'pretix.plugins.banktransfer', 'pretix.plugins.stripe', 'pretix.plugins.paypal', + 'pretix.plugins.paypal2', 'pretix.plugins.ticketoutputpdf', 'pretix.plugins.sendmail', 'pretix.plugins.statistics', diff --git a/src/setup.py b/src/setup.py index 9547b747a3..99a5702bd2 100644 --- a/src/setup.py +++ b/src/setup.py @@ -203,6 +203,8 @@ setup( 'openpyxl==3.0.*', 'packaging', 'paypalrestsdk==1.13.*', + 'paypal-checkout-serversdk==1.0.*', + 'PyJWT==2.0.*', 'phonenumberslite==8.12.*', 'Pillow==9.1.*', 'protobuf==3.19.*', diff --git a/src/tests/control/test_events.py b/src/tests/control/test_events.py index cc9a6db658..0d56f75d30 100644 --- a/src/tests/control/test_events.py +++ b/src/tests/control/test_events.py @@ -299,16 +299,16 @@ class EventsTest(SoupTest): def test_plugins(self): doc = self.get_doc('/control/event/%s/%s/settings/plugins' % (self.orga1.slug, self.event1.slug)) - self.assertIn("PayPal", doc.select(".form-plugins")[0].text) - self.assertIn("Enable", doc.select("[name=\"plugin:pretix.plugins.paypal\"]")[0].text) + self.assertIn("Stripe", doc.select(".form-plugins")[0].text) + self.assertIn("Enable", doc.select("[name=\"plugin:pretix.plugins.stripe\"]")[0].text) doc = self.post_doc('/control/event/%s/%s/settings/plugins' % (self.orga1.slug, self.event1.slug), - {'plugin:pretix.plugins.paypal': 'enable'}) - self.assertIn("Disable", doc.select("[name=\"plugin:pretix.plugins.paypal\"]")[0].text) + {'plugin:pretix.plugins.stripe': 'enable'}) + self.assertIn("Disable", doc.select("[name=\"plugin:pretix.plugins.stripe\"]")[0].text) doc = self.post_doc('/control/event/%s/%s/settings/plugins' % (self.orga1.slug, self.event1.slug), - {'plugin:pretix.plugins.paypal': 'disable'}) - self.assertIn("Enable", doc.select("[name=\"plugin:pretix.plugins.paypal\"]")[0].text) + {'plugin:pretix.plugins.stripe': 'disable'}) + self.assertIn("Enable", doc.select("[name=\"plugin:pretix.plugins.stripe\"]")[0].text) def test_testmode_enable(self): self.event1.testmode = False diff --git a/src/tests/multidomain/test_plugin_handler.py b/src/tests/multidomain/test_plugin_handler.py index 5a62cd69c9..9866a8db80 100644 --- a/src/tests/multidomain/test_plugin_handler.py +++ b/src/tests/multidomain/test_plugin_handler.py @@ -57,12 +57,8 @@ def test_require_live(event, client): event.save() r = client.get('/mrmcd/2015/paypal/abort/', follow=False) assert r.status_code == 302 - r = client.get('/mrmcd/2015/paypal/webhook/', follow=False) - assert r.status_code == 405 event.live = False event.save() r = client.get('/mrmcd/2015/paypal/abort/', follow=False) assert r.status_code == 403 - r = client.get('/mrmcd/2015/paypal/webhook/', follow=False) - assert r.status_code == 405 diff --git a/src/tests/plugins/paypal/__init__.py b/src/tests/plugins/paypal/__init__.py new file mode 100644 index 0000000000..c4b4deab33 --- /dev/null +++ b/src/tests/plugins/paypal/__init__.py @@ -0,0 +1,33 @@ +# +# 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 +# . +# + +# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of +# the Apache License 2.0 can be obtained at . +# +# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A +# full history of changes and contributors is available at . +# +# This file contains Apache-licensed contributions copyrighted by: Tobias Kunze +# +# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under the License. diff --git a/src/tests/plugins/paypal/test_webhook.py b/src/tests/plugins/paypal/test_webhook.py index 7395f619ff..98558067c3 100644 --- a/src/tests/plugins/paypal/test_webhook.py +++ b/src/tests/plugins/paypal/test_webhook.py @@ -241,7 +241,7 @@ def test_webhook_all_good(env, client, monkeypatch): @pytest.mark.django_db -def test_webhook_global(env, client, monkeypatch): +def test_webhook_mark_paid(env, client, monkeypatch): order = env[1] order.status = Order.STATUS_PENDING order.save() @@ -282,49 +282,9 @@ def test_webhook_global(env, client, monkeypatch): assert order.status == Order.STATUS_PAID -@pytest.mark.django_db -def test_webhook_mark_paid(env, client, monkeypatch): - order = env[1] - order.status = Order.STATUS_PENDING - order.save() - with scopes_disabled(): - order.payments.update(state=OrderPayment.PAYMENT_STATE_PENDING) - - charge = get_test_charge(env[1]) - monkeypatch.setattr("paypalrestsdk.Sale.find", lambda *args: charge) - monkeypatch.setattr("pretix.plugins.paypal.payment.Paypal.init_api", lambda *args: None) - - client.post('/dummy/dummy/paypal/webhook/', json.dumps( - { - "id": "WH-2WR32451HC0233532-67976317FL4543714", - "create_time": "2014-10-23T17:23:52Z", - "resource_type": "sale", - "event_type": "PAYMENT.SALE.COMPLETED", - "summary": "A successful sale payment was made for $ 0.48 USD", - "resource": { - "amount": { - "total": "-0.01", - "currency": "USD" - }, - "id": "36C38912MN9658832", - "parent_payment": "PAY-5YK922393D847794YKER7MUI", - "update_time": "2014-10-31T15:41:51Z", - "state": "completed", - "create_time": "2014-10-31T15:41:51Z", - "links": [], - "sale_id": "9T0916710M1105906" - }, - "links": [], - "event_version": "1.0" - } - ), content_type='application_json') - - order.refresh_from_db() - assert order.status == Order.STATUS_PAID - - @pytest.mark.django_db def test_webhook_refund1(env, client, monkeypatch): + order = env[1] charge = get_test_charge(env[1]) charge['state'] = 'refunded' refund = get_test_refund(env[1]) @@ -332,8 +292,9 @@ def test_webhook_refund1(env, client, monkeypatch): monkeypatch.setattr("paypalrestsdk.Sale.find", lambda *args: charge) monkeypatch.setattr("paypalrestsdk.Refund.find", lambda *args: refund) monkeypatch.setattr("pretix.plugins.paypal.payment.Paypal.init_api", lambda *args: None) + ReferencedPayPalObject.objects.create(order=order, reference="PAY-5YK922393D847794YKER7MUI") - client.post('/dummy/dummy/paypal/webhook/', json.dumps( + client.post('/_paypal/webhook/', json.dumps( { # Sample obtained in a sandbox webhook "id": "WH-9K829080KA1622327-31011919VC6498738", @@ -380,6 +341,7 @@ def test_webhook_refund1(env, client, monkeypatch): @pytest.mark.django_db def test_webhook_refund2(env, client, monkeypatch): + order = env[1] charge = get_test_charge(env[1]) charge['state'] = 'refunded' refund = get_test_refund(env[1]) @@ -387,8 +349,9 @@ def test_webhook_refund2(env, client, monkeypatch): monkeypatch.setattr("paypalrestsdk.Sale.find", lambda *args: charge) monkeypatch.setattr("paypalrestsdk.Refund.find", lambda *args: refund) monkeypatch.setattr("pretix.plugins.paypal.payment.Paypal.init_api", lambda *args: None) + ReferencedPayPalObject.objects.create(order=order, reference="PAY-5YK922393D847794YKER7MUI") - client.post('/dummy/dummy/paypal/webhook/', json.dumps( + client.post('/_paypal/webhook/', json.dumps( { # Sample obtained in the webhook simulator "id": "WH-2N242548W9943490U-1JU23391CS4765624", diff --git a/src/tests/plugins/paypal2/__init__.py b/src/tests/plugins/paypal2/__init__.py new file mode 100644 index 0000000000..c4b4deab33 --- /dev/null +++ b/src/tests/plugins/paypal2/__init__.py @@ -0,0 +1,33 @@ +# +# 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 +# . +# + +# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of +# the Apache License 2.0 can be obtained at . +# +# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A +# full history of changes and contributors is available at . +# +# This file contains Apache-licensed contributions copyrighted by: Tobias Kunze +# +# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under the License. diff --git a/src/tests/plugins/paypal2/test_checkout.py b/src/tests/plugins/paypal2/test_checkout.py new file mode 100644 index 0000000000..a27c91e096 --- /dev/null +++ b/src/tests/plugins/paypal2/test_checkout.py @@ -0,0 +1,141 @@ +# +# 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 +# . +# + +# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of +# the Apache License 2.0 can be obtained at . +# +# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A +# full history of changes and contributors is available at . +# +# This file contains Apache-licensed contributions copyrighted by: Tobias Kunze +# +# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under the License. + +import datetime + +import pytest +from django.utils.timezone import now + +from pretix.base.models import ( + CartPosition, Event, Item, ItemCategory, Organizer, Quota, +) +from pretix.testutils.sessions import add_cart_session, get_cart_session_key + + +@pytest.fixture +def env(client): + orga = Organizer.objects.create(name='CCC', slug='ccc') + event = Event.objects.create( + organizer=orga, name='30C3', slug='30c3', + date_from=datetime.datetime(now().year + 1, 12, 26, tzinfo=datetime.timezone.utc), + plugins='pretix.plugins.paypal2', + live=True + ) + category = ItemCategory.objects.create(event=event, name="Everything", position=0) + quota_tickets = Quota.objects.create(event=event, name='Tickets', size=5) + ticket = Item.objects.create(event=event, name='Early-bird ticket', + category=category, default_price=23, admission=True) + quota_tickets.items.add(ticket) + event.settings.set('attendee_names_asked', False) + event.settings.set('payment_paypal__enabled', True) + event.settings.set('payment_paypal__fee_abs', 3) + event.settings.set('payment_paypal_endpoint', 'sandbox') + event.settings.set('payment_paypal_client_id', '12345') + event.settings.set('payment_paypal_secret', '12345') + add_cart_session(client, event, {'email': 'admin@localhost'}) + return client, ticket + + +class Object(): + pass + + +def get_test_order(): + return {'id': '04F89033701558004', + 'intent': 'CAPTURE', + 'status': 'APPROVED', + 'purchase_units': [{'reference_id': 'default', + 'amount': {'currency_code': 'EUR', 'value': '43.59'}, + 'payee': {'merchant_id': 'G6R2B9YXADKWW'}, + 'description': 'Event tickets for PayPal v2', + 'custom_id': 'PAYPALV2', + 'soft_descriptor': 'MARTINFACIL'}], + 'payer': {'name': {'given_name': 'test', 'surname': 'buyer'}, + 'email_address': 'dummy@dummy.dummy', + 'payer_id': 'Q739JNKWH67HE', + 'address': {'country_code': 'DE'}}, + 'create_time': '2022-04-28T13:10:58Z', + 'links': [{'href': 'https://api.sandbox.paypal.com/v2/checkout/orders/04F89033701558004', + 'rel': 'self', + 'method': 'GET'}, + {'href': 'https://api.sandbox.paypal.com/v2/checkout/orders/04F89033701558004', + 'rel': 'update', + 'method': 'PATCH'}, + {'href': 'https://api.sandbox.paypal.com/v2/checkout/orders/04F89033701558004/capture', + 'rel': 'capture', + 'method': 'POST'}]} + + +@pytest.mark.django_db +def test_payment(env, monkeypatch): + def init_api(self): + class Client(): + environment = Object() + environment.client_id = '12345' + environment.merchant_id = 'G6R2B9YXADKWW' + + def execute(self, request): + response = Object() + response.result = Object() + response.result.status = 'APPROVED' + return response + + self.client = Client() + + order = get_test_order() + monkeypatch.setattr("paypalcheckoutsdk.orders.OrdersGetRequest", lambda *args: order) + monkeypatch.setattr("pretix.plugins.paypal2.payment.PaypalMethod.init_api", init_api) + + client, ticket = env + session_key = get_cart_session_key(client, ticket.event) + CartPosition.objects.create( + event=ticket.event, cart_id=session_key, item=ticket, + price=23, expires=now() + datetime.timedelta(minutes=10) + ) + client.get('/%s/%s/checkout/payment/' % (ticket.event.organizer.slug, ticket.event.slug), follow=True) + client.post('/%s/%s/checkout/questions/' % (ticket.event.organizer.slug, ticket.event.slug), { + 'email': 'admin@localhost' + }, follow=True) + + session = client.session + session['payment_paypal_oid'] = '04F89033701558004' + session.save() + + response = client.post('/%s/%s/checkout/payment/' % (ticket.event.organizer.slug, ticket.event.slug), { + 'payment': 'paypal', + 'payment_paypal_wallet_oid': '04F89033701558004', + 'payment_paypal_wallet_payer': 'Q739JNKWH67HE', + }) + print(response.content.decode()) + assert response['Location'] == '/ccc/30c3/checkout/confirm/' diff --git a/src/tests/plugins/paypal2/test_settings.py b/src/tests/plugins/paypal2/test_settings.py new file mode 100644 index 0000000000..686c37dba3 --- /dev/null +++ b/src/tests/plugins/paypal2/test_settings.py @@ -0,0 +1,67 @@ +# +# 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 +# . +# + +# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of +# the Apache License 2.0 can be obtained at . +# +# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A +# full history of changes and contributors is available at . +# +# This file contains Apache-licensed contributions copyrighted by: Tobias Kunze +# +# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under the License. + +import datetime + +import pytest + +from pretix.base.models import Event, Organizer, Team, User + + +@pytest.fixture +def env(client): + orga = Organizer.objects.create(name='CCC', slug='ccc') + event = Event.objects.create( + organizer=orga, name='30C3', slug='30c3', + date_from=datetime.datetime(2013, 12, 26, tzinfo=datetime.timezone.utc), + plugins='pretix.plugins.paypal2', + live=True + ) + event.settings.set('attendee_names_asked', False) + event.settings.set('payment_paypal__enabled', True) + user = User.objects.create_user('dummy@dummy.dummy', 'dummy') + t = Team.objects.create(organizer=event.organizer, can_change_event_settings=True) + t.members.add(user) + t.limit_events.add(event) + client.force_login(user) + return client, event + + +@pytest.mark.django_db +def test_settings(env): + client, event = env + response = client.get('/control/event/%s/%s/settings/payment/paypal_settings' % (event.organizer.slug, event.slug), + follow=True) + assert response.status_code == 200 + assert 'paypal__enabled' in response.rendered_content diff --git a/src/tests/plugins/paypal2/test_webhook.py b/src/tests/plugins/paypal2/test_webhook.py new file mode 100644 index 0000000000..190151e59d --- /dev/null +++ b/src/tests/plugins/paypal2/test_webhook.py @@ -0,0 +1,688 @@ +# +# 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 datetime import timedelta +from decimal import Decimal + +import pytest +from django.utils.timezone import now +from django_scopes import scopes_disabled + +from pretix.base.models import ( + Event, Order, OrderPayment, OrderRefund, Organizer, Team, User, +) +from pretix.plugins.paypal.models import ReferencedPayPalObject + + +@pytest.fixture +def env(): + user = User.objects.create_user('dummy@dummy.dummy', 'dummy') + o = Organizer.objects.create(name='Dummy', slug='dummy') + event = Event.objects.create( + organizer=o, name='Dummy', slug='dummy', plugins='pretix.plugins.paypal2', + date_from=now(), live=True + ) + t = Team.objects.create(organizer=event.organizer, can_view_orders=True, can_change_orders=True) + t.members.add(user) + t.limit_events.add(event) + o1 = Order.objects.create( + code='FOOBAR', event=event, email='dummy@dummy.test', + status=Order.STATUS_PAID, + datetime=now(), expires=now() + timedelta(days=10), + total=Decimal('43.59'), + ) + o1.payments.create( + amount=o1.total, + provider='paypal', + state=OrderPayment.PAYMENT_STATE_CONFIRMED, + info=json.dumps({ + "id": "806440346Y391300T", + "status": "COMPLETED", + "purchase_units": [ + { + "reference_id": "default", + "shipping": { + "name": { + "full_name": "test buyer" + } + }, + "payments": { + "captures": [ + { + "id": "22A4162004478570J", + "status": "COMPLETED", + "amount": { + "currency_code": "EUR", + "value": "43.59" + }, + "final_capture": True, + "disbursement_mode": "INSTANT", + "seller_protection": { + "status": "ELIGIBLE", + "dispute_categories": [ + "ITEM_NOT_RECEIVED", + "UNAUTHORIZED_TRANSACTION" + ] + }, + "seller_receivable_breakdown": { + "gross_amount": { + "currency_code": "EUR", + "value": "43.59" + }, + "paypal_fee": { + "currency_code": "EUR", + "value": "1.18" + }, + "net_amount": { + "currency_code": "EUR", + "value": "42.41" + } + }, + "custom_id": "Order PAYPALV2-JWJGC", + "links": [ + { + "href": "https://api.sandbox.paypal.com/v2/payments/captures/22A4162004478570J", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.sandbox.paypal.com/v2/payments/captures/22A4162004478570J/refund", + "rel": "refund", + "method": "POST" + }, + { + "href": "https://api.sandbox.paypal.com/v2/checkout/orders/806440346Y391300T", + "rel": "up", + "method": "GET" + } + ], + "create_time": "2022-04-28T12:00:22Z", + "update_time": "2022-04-28T12:00:22Z" + } + ] + } + } + ], + "payer": { + "name": { + "given_name": "test", + "surname": "buyer" + }, + "email_address": "dummy@dummy.dummy", + "payer_id": "Q739JNKWH67HE", + "address": { + "country_code": "DE" + } + }, + "links": [ + { + "href": "https://api.sandbox.paypal.com/v2/checkout/orders/806440346Y391300T", + "rel": "self", + "method": "GET" + } + ] + }) + ) + return event, o1 + + +def get_test_order(): + return {'id': '806440346Y391300T', + 'intent': 'CAPTURE', + 'status': 'COMPLETED', + 'purchase_units': [{'reference_id': 'default', + 'amount': {'currency_code': 'EUR', 'value': '43.59'}, + 'payee': {'email_address': 'dummy-facilitator@dummy.dummy', + 'merchant_id': 'G6R2B9YXADKWW'}, + 'description': 'Order JWJGC for PayPal v2', + 'custom_id': 'Order PAYPALV2-JWJGC', + 'soft_descriptor': 'MARTINFACIL', + 'payments': {'captures': [{'id': '22A4162004478570J', + 'status': 'COMPLETED', + 'amount': {'currency_code': 'EUR', 'value': '43.59'}, + 'final_capture': True, + 'disbursement_mode': 'INSTANT', + 'seller_protection': {'status': 'ELIGIBLE', + 'dispute_categories': [ + 'ITEM_NOT_RECEIVED', + 'UNAUTHORIZED_TRANSACTION']}, + 'seller_receivable_breakdown': { + 'gross_amount': {'currency_code': 'EUR', + 'value': '43.59'}, + 'paypal_fee': {'currency_code': 'EUR', 'value': '1.18'}, + 'net_amount': {'currency_code': 'EUR', + 'value': '42.41'}}, + 'custom_id': 'Order PAYPALV2-JWJGC', + 'links': [{ + 'href': 'https://api.sandbox.paypal.com/v2/payments/captures/22A4162004478570J', + 'rel': 'self', + 'method': 'GET'}, + { + 'href': 'https://api.sandbox.paypal.com/v2/payments/captures/22A4162004478570J/refund', + 'rel': 'refund', + 'method': 'POST'}, + { + 'href': 'https://api.sandbox.paypal.com/v2/checkout/orders/806440346Y391300T', + 'rel': 'up', + 'method': 'GET'}], + 'create_time': '2022-04-28T12:00:22Z', + 'update_time': '2022-04-28T12:00:22Z'}]}}], + 'payer': {'name': {'given_name': 'test', 'surname': 'buyer'}, + 'email_address': 'dummy@dummy.dummy', + 'payer_id': 'Q739JNKWH67HE', + 'address': {'country_code': 'DE'}}, + 'create_time': '2022-04-28T11:59:59Z', + 'update_time': '2022-04-28T12:00:22Z', + 'links': [{'href': 'https://api.sandbox.paypal.com/v2/checkout/orders/806440346Y391300T', + 'rel': 'self', + 'method': 'GET'}]} + + +def get_test_refund(): + return { + "id": "1YK122615V244890X", + "amount": { + "currency_code": "EUR", + "value": "43.59" + }, + "seller_payable_breakdown": { + "gross_amount": { + "currency_code": "EUR", + "value": "43.59" + }, + "paypal_fee": { + "currency_code": "EUR", + "value": "1.18" + }, + "net_amount": { + "currency_code": "EUR", + "value": "42.41" + }, + "total_refunded_amount": { + "currency_code": "EUR", + "value": "43.59" + } + }, + "custom_id": "Order PAYPALV2-JWJGC", + "status": "COMPLETED", + "create_time": "2022-04-28T07:50:56-07:00", + "update_time": "2022-04-28T07:50:56-07:00", + "links": [ + { + "href": "https://api.sandbox.paypal.com/v2/payments/refunds/1YK122615V244890X", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.sandbox.paypal.com/v2/payments/captures/22A4162004478570J", + "rel": "up", + "method": "GET" + } + ] + } + + +class Object(): + pass + + +def init_api(self): + class Client(): + environment = Object() + environment.client_id = '12345' + environment.merchant_id = 'G6R2B9YXADKWW' + + def execute(self, request): + response = Object() + response.result = request + return response + + self.client = Client() + + +@pytest.mark.django_db +def test_webhook_all_good(env, client, monkeypatch): + order = env[1] + pp_order = get_test_order() + monkeypatch.setattr("paypalcheckoutsdk.orders.OrdersGetRequest", lambda *args: pp_order) + monkeypatch.setattr("pretix.plugins.paypal2.payment.PaypalMethod.init_api", init_api) + + with scopes_disabled(): + ReferencedPayPalObject.objects.create(order=order, payment=order.payments.first(), + reference="806440346Y391300T") + + client.post('/_paypal/webhook/', json.dumps( + { + "id": "WH-4T867178D0574904F-7TT11736YU643990P", + "create_time": "2022-04-28T12:00:37.077Z", + "resource_type": "checkout-order", + "event_type": "CHECKOUT.ORDER.COMPLETED", + "summary": "Checkout Order Completed", + "resource": { + "update_time": "2022-04-28T12:00:22Z", + "create_time": "2022-04-28T11:59:59Z", + "purchase_units": [ + { + "reference_id": "default", + "amount": { + "currency_code": "EUR", + "value": "43.59" + }, + "payee": { + "email_address": "dummy-facilitator@dummy.dummy", + "merchant_id": "G6R2B9YXADKWW" + }, + "description": "Order JWJGC for PayPal v2", + "custom_id": "Order PAYPALV2-JWJGC", + "soft_descriptor": "MARTINFACIL", + "payments": { + "captures": [ + { + "id": "22A4162004478570J", + "status": "COMPLETED", + "amount": { + "currency_code": "EUR", + "value": "43.59" + }, + "final_capture": True, + "disbursement_mode": "INSTANT", + "seller_protection": { + "status": "ELIGIBLE", + "dispute_categories": [ + "ITEM_NOT_RECEIVED", + "UNAUTHORIZED_TRANSACTION" + ] + }, + "seller_receivable_breakdown": { + "gross_amount": { + "currency_code": "EUR", + "value": "43.59" + }, + "paypal_fee": { + "currency_code": "EUR", + "value": "1.18" + }, + "net_amount": { + "currency_code": "EUR", + "value": "42.41" + } + }, + "custom_id": "Order PAYPALV2-JWJGC", + "links": [ + { + "href": "https://api.sandbox.paypal.com/v2/payments/captures/22A4162004478570J", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.sandbox.paypal.com/v2/payments/captures/22A4162004478570J/refund", + "rel": "refund", + "method": "POST" + }, + { + "href": "https://api.sandbox.paypal.com/v2/checkout/orders/806440346Y391300T", + "rel": "up", + "method": "GET" + } + ], + "create_time": "2022-04-28T12:00:22Z", + "update_time": "2022-04-28T12:00:22Z" + } + ] + } + } + ], + "links": [ + { + "href": "https://api.sandbox.paypal.com/v2/checkout/orders/806440346Y391300T", + "rel": "self", + "method": "GET" + } + ], + "id": "806440346Y391300T", + "intent": "CAPTURE", + "payer": { + "name": { + "given_name": "test", + "surname": "buyer" + }, + "email_address": "dummy@dummy.dummy", + "payer_id": "Q739JNKWH67HE", + "address": { + "country_code": "DE" + } + }, + "status": "COMPLETED" + }, + "status": "SUCCESS", + "links": [ + { + "href": "https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-4T867178D0574904F-7TT11736YU643990P", + "rel": "self", + "method": "GET", + "encType": "application/json" + }, + { + "href": "https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-4T867178D0574904F-7TT11736YU643990P/resend", + "rel": "resend", + "method": "POST", + "encType": "application/json" + } + ], + "event_version": "1.0", + "resource_version": "2.0" + } + ), content_type='application_json') + + order = env[1] + order.refresh_from_db() + assert order.status == Order.STATUS_PAID + + +@pytest.mark.django_db +def test_webhook_mark_paid(env, client, monkeypatch): + order = env[1] + order.status = Order.STATUS_PENDING + order.save() + with scopes_disabled(): + order.payments.update(state=OrderPayment.PAYMENT_STATE_PENDING) + + pp_order = get_test_order() + monkeypatch.setattr("paypalcheckoutsdk.orders.OrdersGetRequest", lambda *args: pp_order) + monkeypatch.setattr("pretix.plugins.paypal2.payment.PaypalMethod.init_api", init_api) + with scopes_disabled(): + ReferencedPayPalObject.objects.create(order=order, payment=order.payments.first(), + reference="806440346Y391300T") + + client.post('/_paypal/webhook/', json.dumps( + { + "id": "WH-88L014580L300952M-4BX97184625330932", + "create_time": "2022-04-28T12:00:26.840Z", + "resource_type": "capture", + "event_type": "PAYMENT.CAPTURE.COMPLETED", + "summary": "Payment completed for EUR 43.59 EUR", + "resource": { + "disbursement_mode": "INSTANT", + "amount": { + "value": "43.59", + "currency_code": "EUR" + }, + "seller_protection": { + "dispute_categories": [ + "ITEM_NOT_RECEIVED", + "UNAUTHORIZED_TRANSACTION" + ], + "status": "ELIGIBLE" + }, + "supplementary_data": { + "related_ids": { + "order_id": "806440346Y391300T" + } + }, + "update_time": "2022-04-28T12:00:22Z", + "create_time": "2022-04-28T12:00:22Z", + "final_capture": True, + "seller_receivable_breakdown": { + "paypal_fee": { + "value": "1.18", + "currency_code": "EUR" + }, + "gross_amount": { + "value": "43.59", + "currency_code": "EUR" + }, + "net_amount": { + "value": "42.41", + "currency_code": "EUR" + } + }, + "custom_id": "Order PAYPALV2-JWJGC", + "links": [ + { + "method": "GET", + "rel": "self", + "href": "https://api.sandbox.paypal.com/v2/payments/captures/22A4162004478570J" + }, + { + "method": "POST", + "rel": "refund", + "href": "https://api.sandbox.paypal.com/v2/payments/captures/22A4162004478570J/refund" + }, + { + "method": "GET", + "rel": "up", + "href": "https://api.sandbox.paypal.com/v2/checkout/orders/806440346Y391300T" + } + ], + "id": "22A4162004478570J", + "status": "COMPLETED" + }, + "status": "SUCCESS", + "links": [ + { + "href": "https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-88L014580L300952M-4BX97184625330932", + "rel": "self", + "method": "GET", + "encType": "application/json" + }, + { + "href": "https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-88L014580L300952M-4BX97184625330932/resend", + "rel": "resend", + "method": "POST", + "encType": "application/json" + } + ], + "event_version": "1.0", + "resource_version": "2.0" + } + ), content_type='application_json') + + order.refresh_from_db() + assert order.status == Order.STATUS_PAID + + +@pytest.mark.django_db +def test_webhook_refund1(env, client, monkeypatch): + order = env[1] + pp_order = get_test_order() + pp_refund = get_test_refund() + + monkeypatch.setattr("paypalcheckoutsdk.orders.OrdersGetRequest", lambda *args: pp_order) + monkeypatch.setattr("paypalcheckoutsdk.payments.RefundsGetRequest", lambda *args: pp_refund) + monkeypatch.setattr("pretix.plugins.paypal2.payment.PaypalMethod.init_api", init_api) + with scopes_disabled(): + ReferencedPayPalObject.objects.create(order=order, payment=order.payments.first(), + reference="22A4162004478570J") + + client.post('/_paypal/webhook/', json.dumps( + { + "id": "WH-5LJ60612747357339-66248625WA926672S", + "create_time": "2022-04-28T14:51:00.318Z", + "resource_type": "refund", + "event_type": "PAYMENT.CAPTURE.REFUNDED", + "summary": "A EUR 43.59 EUR capture payment was refunded", + "resource": { + "seller_payable_breakdown": { + "total_refunded_amount": { + "value": "43.59", + "currency_code": "EUR" + }, + "paypal_fee": { + "value": "1.18", + "currency_code": "EUR" + }, + "gross_amount": { + "value": "42.41", + "currency_code": "EUR" + }, + "net_amount": { + "value": "43.59", + "currency_code": "EUR" + } + }, + "amount": { + "value": "43.59", + "currency_code": "EUR" + }, + "update_time": "2022-04-28T07:50:56-07:00", + "create_time": "2022-04-28T07:50:56-07:00", + "custom_id": "Order PAYPALV2-JWJGC", + "links": [ + { + "method": "GET", + "rel": "self", + "href": "https://api.sandbox.paypal.com/v2/payments/refunds/1YK122615V244890X" + }, + { + "method": "GET", + "rel": "up", + "href": "https://api.sandbox.paypal.com/v2/payments/captures/22A4162004478570J" + } + ], + "id": "1YK122615V244890X", + "status": "COMPLETED" + }, + "status": "SUCCESS", + "links": [ + { + "href": "https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-5LJ60612747357339-66248625WA926672S", + "rel": "self", + "method": "GET", + "encType": "application/json" + }, + { + "href": "https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-5LJ60612747357339-66248625WA926672S/resend", + "rel": "resend", + "method": "POST", + "encType": "application/json" + } + ], + "event_version": "1.0", + "resource_version": "2.0" + } + ), content_type='application_json') + + order = env[1] + order.refresh_from_db() + assert order.status == Order.STATUS_PAID + + with scopes_disabled(): + r = order.refunds.first() + assert r.provider == 'paypal' + assert r.amount == order.total + assert r.payment == order.payments.first() + assert r.state == OrderRefund.REFUND_STATE_EXTERNAL + assert r.source == OrderRefund.REFUND_SOURCE_EXTERNAL + + +@pytest.mark.django_db +def test_webhook_refund2(env, client, monkeypatch): + order = env[1] + pp_order = get_test_order() + pp_refund = get_test_refund() + + monkeypatch.setattr("paypalcheckoutsdk.orders.OrdersGetRequest", lambda *args: pp_order) + monkeypatch.setattr("paypalcheckoutsdk.payments.RefundsGetRequest", lambda *args: pp_refund) + monkeypatch.setattr("pretix.plugins.paypal2.payment.PaypalMethod.init_api", init_api) + with scopes_disabled(): + ReferencedPayPalObject.objects.create(order=order, payment=order.payments.first(), + reference="22A4162004478570J") + + client.post('/_paypal/webhook/', json.dumps( + { + "id": "WH-7FL378472F5218625-6WC87835CR8751809", + "create_time": "2022-04-28T14:56:08.160Z", + "resource_type": "refund", + "event_type": "PAYMENT.CAPTURE.REFUNDED", + "summary": "A EUR 43.59 EUR capture payment was refunded", + "resource": { + "seller_payable_breakdown": { + "total_refunded_amount": { + "value": "43.59", + "currency_code": "EUR" + }, + "paypal_fee": { + "value": "01.18", + "currency_code": "EUR" + }, + "gross_amount": { + "value": "43.59", + "currency_code": "EUR" + }, + "net_amount": { + "value": "42.41", + "currency_code": "EUR" + } + }, + "amount": { + "value": "43.59", + "currency_code": "EUR" + }, + "update_time": "2022-04-28T07:56:04-07:00", + "create_time": "2022-04-28T07:56:04-07:00", + "custom_id": "Order PAYPALV2-JWJGC", + "links": [ + { + "method": "GET", + "rel": "self", + "href": "https://api.sandbox.paypal.com/v2/payments/refunds/3K87087190824201K" + }, + { + "method": "GET", + "rel": "up", + "href": "https://api.sandbox.paypal.com/v2/payments/captures/22A4162004478570J" + } + ], + "id": "3K87087190824201K", + "status": "COMPLETED" + }, + "status": "SUCCESS", + "links": [ + { + "href": "https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-7FL378472F5218625-6WC87835CR8751809", + "rel": "self", + "method": "GET", + "encType": "application/json" + }, + { + "href": "https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-7FL378472F5218625-6WC87835CR8751809/resend", + "rel": "resend", + "method": "POST", + "encType": "application/json" + } + ], + "event_version": "1.0", + "resource_version": "2.0" + } + ), content_type='application_json') + + order = env[1] + order.refresh_from_db() + assert order.status == Order.STATUS_PAID + + with scopes_disabled(): + r = order.refunds.first() + assert r.provider == 'paypal' + assert r.amount == order.total + assert r.payment == order.payments.first() + assert r.state == OrderRefund.REFUND_STATE_EXTERNAL + assert r.source == OrderRefund.REFUND_SOURCE_EXTERNAL