diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index a34e128a71..9a14fceccd 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -700,8 +700,8 @@ class Event(EventMixin, LoggedModel): from ..signals import event_copy_data from . import ( - Discount, Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, Question, - Quota, + Discount, Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, + Question, Quota, ) # Note: avoid self.set_active_plugins(), it causes trouble e.g. for the badges plugin. diff --git a/src/pretix/plugins/paypal/apps.py b/src/pretix/plugins/paypal/apps.py index d9f2921f4e..6e6d8fedd6 100644 --- a/src/pretix/plugins/paypal/apps.py +++ b/src/pretix/plugins/paypal/apps.py @@ -33,7 +33,6 @@ # License for the specific language governing permissions and limitations under the License. from django.apps import AppConfig -from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ from pretix import __version__ as version @@ -55,12 +54,3 @@ class PaypalApp(AppConfig): def ready(self): from . import signals # NOQA - - @cached_property - def compatibility_errors(self): - errs = [] - try: - import paypalrestsdk # NOQA - except ImportError: - errs.append("Python package 'paypalrestsdk' is not installed.") - return errs diff --git a/src/pretix/plugins/paypal/client/core/environment.py b/src/pretix/plugins/paypal/client/core/environment.py new file mode 100644 index 0000000000..2be81982af --- /dev/null +++ b/src/pretix/plugins/paypal/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/paypal/client/core/paypal_http_client.py b/src/pretix/plugins/paypal/client/core/paypal_http_client.py new file mode 100644 index 0000000000..1e78f55f9a --- /dev/null +++ b/src/pretix/plugins/paypal/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/paypal/client/customer/partner_referral_create_request.py b/src/pretix/plugins/paypal/client/customer/partner_referral_create_request.py new file mode 100644 index 0000000000..b0ca081bac --- /dev/null +++ b/src/pretix/plugins/paypal/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/paypal/client/customer/partners_merchantintegrations_get_request.py b/src/pretix/plugins/paypal/client/customer/partners_merchantintegrations_get_request.py new file mode 100644 index 0000000000..68ce135e24 --- /dev/null +++ b/src/pretix/plugins/paypal/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/paypal/payment.py b/src/pretix/plugins/paypal/payment.py index 3d9cf86a1d..e7475e1800 100644 --- a/src/pretix/plugins/paypal/payment.py +++ b/src/pretix/plugins/paypal/payment.py @@ -31,34 +31,49 @@ # 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 import urllib.parse from collections import OrderedDict from decimal import Decimal -import paypalrestsdk -import paypalrestsdk.exceptions from django import forms from django.contrib import messages -from django.core import signing 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 paypalrestsdk.exceptions import BadRequest, UnauthorizedAccess -from paypalrestsdk.openid_connect import Tokeninfo +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 +from pretix.multidomain.urlreverse import build_absolute_uri, eventreverse +from pretix.plugins.paypal.client.core.environment import ( + LiveEnvironment, SandboxEnvironment, +) +from pretix.plugins.paypal.client.core.paypal_http_client import ( + PayPalHttpClient, +) +from pretix.plugins.paypal.client.customer.partner_referral_create_request import ( + PartnerReferralCreateRequest, +) from pretix.plugins.paypal.models import ReferencedPayPalObject logger = logging.getLogger('pretix.plugins.paypal') @@ -69,32 +84,21 @@ SUPPORTED_CURRENCIES = ['AUD', 'BRL', 'CAD', 'CZK', 'DKK', 'EUR', 'HKD', 'HUF', LOCAL_ONLY_CURRENCIES = ['INR'] -class Paypal(BasePaymentProvider): - identifier = 'paypal' +class PaypalSettingsHolder(BasePaymentProvider): + identifier = 'paypal_settings' verbose_name = _('PayPal') - payment_form_fields = OrderedDict([ - ]) + 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 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 - @property def settings_form_fields(self): - if self.settings.connect_client_id and not self.settings.secret: - # PayPal connect + # PayPal connect (legacy) || ISU + if self.settings.connect_client_id and self.settings.connect_secret_key and not self.settings.secret: if self.settings.connect_user_id: fields = [ ('connect_user_id', @@ -103,8 +107,17 @@ class Paypal(BasePaymentProvider): disabled=True )), ] + elif 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', @@ -134,6 +147,68 @@ class Paypal(BasePaymentProvider): )), ] + methods = [ + ('method_wallet', + forms.BooleanField( + label=_('PayPal'), + required=False, + help_text=_( + 'Even if a customer choses 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( @@ -151,8 +226,21 @@ class Paypal(BasePaymentProvider): )), ] + 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 + extra_fields + list(super().settings_form_fields.items()) + fields + methods + extra_fields + list(super().settings_form_fields.items()) ) d.move_to_end('prefix') @@ -160,32 +248,28 @@ class Paypal(BasePaymentProvider): d.move_to_end('_enabled', False) return d - def get_connect_url(self, request): - request.session['payment_paypal_oauth_event'] = request.event.pk - - self.init_api() - return Tokeninfo.authorize_url({'scope': 'openid profile email'}) - def settings_content_render(self, request): settings_content = "" - if self.settings.connect_client_id and not self.settings.secret: - # Use PayPal connect - if not self.settings.connect_user_id: + if self.settings.connect_client_id and self.settings.connect_secret_key and not self.settings.secret: + # Use ISU + if not any([self.settings.isu_merchant_id, self.settings.connect_user_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 connect pretix to an existing ' 'one.'), - self.get_connect_url(request), + isu_referral_url, + 'disabled' if not isu_referral_url else '', _('Connect with {icon} PayPal').format(icon='') ) else: settings_content = ( "" ).format( - reverse('plugins:paypal:oauth.disconnect', kwargs={ + reverse('plugins:paypal:isu.disconnect', kwargs={ 'organizer': self.event.organizer.slug, 'event': self.event.slug, }), @@ -218,114 +302,182 @@ class Paypal(BasePaymentProvider): 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:paypal: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): + # PayPal Connect (legacy) || ISU if self.settings.connect_client_id and not self.settings.secret: - paypalrestsdk.set_config( - mode="sandbox" if "sandbox" in self.settings.connect_endpoint else 'live', - 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'))) + 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', self.settings.get('connect_user_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', self.settings.get('connect_user_id', None)), + partner_id=self.BN + ) + # Manual API integration else: - paypalrestsdk.set_config( - mode="sandbox" if "sandbox" in self.settings.get('endpoint') else 'live', - client_id=self.settings.get('client_id'), - client_secret=self.settings.get('secret')) + 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_id', '') != '' - and request.session.get('payment_paypal_payer', '') != '') + 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/paypal/checkout_payment_form.html') - ctx = {'request': request, 'event': self.event, 'settings': self.settings} + ctx = { + 'request': request, + 'event': self.event, + 'settings': self.settings, + 'method': self.method, + 'xhr': eventreverse(self.event, 'plugins:paypal:xhr', kwargs=build_kwargs()) + } return template.render(ctx) def checkout_prepare(self, request, cart): - 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'] + paypal_order_id = request.POST.get('payment_paypal_{}_oid'.format(self.method), None) - try: - if request.event.settings.payment_paypal_connect_user_id: - try: - tokeninfo = Tokeninfo.create_with_refresh_token(request.event.settings.payment_paypal_connect_refresh_token) - except BadRequest as ex: - ex = json.loads(ex.content) - messages.error(request, '{}: {} ({})'.format( - _('We had trouble communicating with PayPal'), - ex['error_description'], - ex['correlation_id']) - ) - return + # 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() - # Even if the token has been refreshed, calling userinfo() can fail. In this case we just don't - # get the userinfo again and use the payment_paypal_connect_user_id that we already have on file - try: - userinfo = tokeninfo.userinfo() - request.event.settings.payment_paypal_connect_user_id = userinfo.email - except UnauthorizedAccess: - pass - - payee = { - "email": request.event.settings.payment_paypal_connect_user_id, - # If PayPal ever offers a good way to get the MerchantID via the Identifity API, - # we should use it instead of the merchant's eMail-address - # "merchant_id": request.event.settings.payment_paypal_connect_user_id, - } + 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: - payee = {} - - payment = paypalrestsdk.Payment({ - 'header': {'PayPal-Partner-Attribution-Id': 'ramiioSoftwareentwicklung_SP'}, - 'intent': 'sale', - 'payer': { - "payment_method": "paypal", - }, - "redirect_urls": { - "return_url": build_absolute_uri(request.event, 'plugins:paypal:return', kwargs=kwargs), - "cancel_url": build_absolute_uri(request.event, 'plugins:paypal:abort', kwargs=kwargs), - }, - "transactions": [ - { - "item_list": { - "items": [ - { - "name": '{prefix}{orderstring}{postfix}'.format( - prefix='{} '.format(self.settings.prefix) if self.settings.prefix else '', - orderstring=__('Order for %s') % str(request.event), - postfix=' {}'.format(self.settings.postfix) if self.settings.postfix else '' - ), - "quantity": 1, - "price": self.format_price(cart['total']), - "currency": request.event.currency - } - ] - }, - "amount": { - "currency": request.event.currency, - "total": self.format_price(cart['total']) - }, - "description": __('Event tickets for {event}').format(event=request.event.name), - "payee": payee, - "custom": '{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 - return self._create_payment(request, payment) - except paypalrestsdk.exceptions.ConnectionError as e: - messages.error(request, _('We had trouble communicating with PayPal')) - logger.exception('Error on creating payment: ' + str(e)) + 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, { @@ -359,26 +511,92 @@ class Paypal(BasePaymentProvider): def abort_pending_allowed(self): return False - def _create_payment(self, request, payment): - if payment.create(): - if payment.state not in ('created', 'approved', 'pending'): - messages.error(request, _('We had trouble communicating with PayPal')) - logger.error('Invalid payment state: ' + str(payment)) - return - request.session['payment_paypal_id'] = payment.id - for link in payment.links: - if link.method == "REDIRECT" and link.rel == "approval_url": - if request.session.get('iframe_session', False): - signer = signing.Signer(salt='safe-redirect') - return ( - build_absolute_uri(request.event, 'plugins:paypal:redirect') + '?url=' + - urllib.parse.quote(signer.sign(link.href)) - ) - else: - return str(link.href) + 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'] + + # PayPal Connect (legacy) + if request.event.settings.payment_paypal_connect_user_id: + payee = { + "email": request.event.settings.payment_paypal_connect_user_id, + } + # ISU + elif 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:paypal:return', kwargs=kwargs), + 'cancel_url': build_absolute_uri(request.event, 'plugins:paypal:abort', kwargs=kwargs), + }, + }) + response = self.client.execute(paymentreq) + except IOError as e: messages.error(request, _('We had trouble communicating with PayPal')) - logger.error('Error on creating payment: ' + str(payment.error)) + 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: """ @@ -390,104 +608,109 @@ class Paypal(BasePaymentProvider): return template.render(ctx) def execute_payment(self, request: HttpRequest, payment: OrderPayment): - if (request.session.get('payment_paypal_id', '') == '' or request.session.get('payment_paypal_payer', '') == ''): - raise PaymentException(_('We were unable to process your payment. See below for details on how to ' - 'proceed.')) + 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() - pp_payment = paypalrestsdk.Payment.find(request.session.get('payment_paypal_id')) - ReferencedPayPalObject.objects.get_or_create(order=payment.order, payment=payment, reference=pp_payment.id) - if str(pp_payment.transactions[0].amount.total) != str(payment.amount) or pp_payment.transactions[0].amount.currency \ - != self.event.currency: - logger.error('Value mismatch: Payment %s vs paypal trans %s' % (payment.id, str(pp_payment))) - raise PaymentException(_('We were unable to process your payment. See below for details on how to ' - 'proceed.')) - - return self._execute_payment(pp_payment, request, payment) - - def _execute_payment(self, payment, request, payment_obj): - if payment.state == 'created': - payment.replace([ - { - "op": "replace", - "path": "/transactions/0/item_list", - "value": { - "items": [ - { - "name": '{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_obj.order.code - ), - postfix=' {}'.format(self.settings.postfix) if self.settings.postfix else '' - ), - "quantity": 1, - "price": self.format_price(payment_obj.amount), - "currency": payment_obj.order.event.currency - } - ] - } - }, - { - "op": "replace", - "path": "/transactions/0/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_obj.order.code - ), - postfix=' {}'.format(self.settings.postfix) if self.settings.postfix else '' - ), - } - ]) + self.init_api() try: - payment.execute({"payer_id": request.session.get('payment_paypal_payer')}) - except paypalrestsdk.exceptions.ConnectionError as e: - messages.error(request, _('We had trouble communicating with PayPal')) - logger.exception('Error on creating payment: ' + str(e)) + 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 - for trans in payment.transactions: - for rr in trans.related_resources: - if hasattr(rr, 'sale') and rr.sale: - if rr.sale.state == 'pending': + ReferencedPayPalObject.objects.get_or_create(order=payment.order, payment=payment, reference=pp_captured_order.id) + 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: + ReferencedPayPalObject.objects.get_or_create(order=payment.order, payment=payment, reference=capture.id) + + 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_obj.info = json.dumps(payment.to_dict()) - payment_obj.state = OrderPayment.PAYMENT_STATE_PENDING - payment_obj.save() + payment.info = json.dumps(pp_captured_order.dict()) + payment.state = OrderPayment.PAYMENT_STATE_PENDING + payment.save() return - payment_obj.refresh_from_db() - if payment.state == 'pending': - messages.warning(request, _('PayPal has not yet approved the payment. We will inform you as soon as the ' - 'payment completed.')) - payment_obj.info = json.dumps(payment.to_dict()) - payment_obj.state = OrderPayment.PAYMENT_STATE_PENDING - payment_obj.save() - return + payment.refresh_from_db() - if payment.state != 'approved': - payment_obj.fail(info=payment.to_dict()) - logger.error('Invalid state: %s' % str(payment)) - raise PaymentException(_('We were unable to process your payment. See below for details on how to ' - 'proceed.')) + 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_obj.state == OrderPayment.PAYMENT_STATE_CONFIRMED: - logger.warning('PayPal success event even though order is already marked as paid') - return + if payment.state == OrderPayment.PAYMENT_STATE_CONFIRMED: + logger.warning('PayPal success event even though order is already marked as paid') + return - try: - payment_obj.info = json.dumps(payment.to_dict()) - payment_obj.save(update_fields=['info']) - payment_obj.confirm() - except Quota.QuotaExceededException as e: - raise PaymentException(str(e)) + 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.')) - return None + 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 @@ -503,39 +726,77 @@ class Paypal(BasePaymentProvider): def matching_id(self, payment: OrderPayment): 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'] + + # 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 - 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, - } + + # 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): - template = get_template('pretixplugins/paypal/control.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} + # Legacy PayPal info-data + if 'purchase_units' not in payment.info_data: + template = get_template('pretixplugins/paypal/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/paypal/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: - return payment.info_data.get('payer', {}).get('payer_info', {}).get('email', '') + # 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: @@ -549,136 +810,95 @@ class Paypal(BasePaymentProvider): self.init_api() try: - sale = None - for res in refund.payment.info_data['transactions'][0]['related_resources']: - for k, v in res.items(): - if k == 'sale': - sale = paypalrestsdk.Sale.find(v['id']) + 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 - if not sale: - pp_payment = paypalrestsdk.Payment.find(refund.payment.info_data['id']) - for res in pp_payment.transactions[0].related_resources: - for k, v in res.to_dict().items(): - if k == 'sale': - sale = paypalrestsdk.Sale.find(v['id']) - break - - pp_refund = sale.refund({ + req = CapturesRefundRequest(pp_payment) + req.request_body({ "amount": { - "total": self.format_price(refund.amount), - "currency": refund.order.event.currency + "value": self.format_price(refund.amount), + "currency_code": refund.order.event.currency } }) - except paypalrestsdk.exceptions.ConnectionError as e: + 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))) - if not pp_refund.success(): + + 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(pp_refund.error) + 'error': str(response.result.status_details.reason) }) - raise PaymentException(_('Refunding the amount via PayPal failed: {}').format(pp_refund.error)) - else: - sale = paypalrestsdk.Payment.find(refund.payment.info_data['id']) - refund.payment.info = json.dumps(sale.to_dict()) - refund.info = json.dumps(pp_refund.to_dict()) - refund.done() + raise PaymentException(_('Refunding the amount via PayPal failed: {}').format(response.result.status_details.reason)) - def payment_prepare(self, request, payment_obj): - self.init_api() + def payment_prepare(self, request, payment): + paypal_order_id = request.POST.get('payment_paypal_{}_oid'.format(self.method), None) - try: - if request.event.settings.payment_paypal_connect_user_id: - try: - tokeninfo = Tokeninfo.create_with_refresh_token(request.event.settings.payment_paypal_connect_refresh_token) - except BadRequest as ex: - ex = json.loads(ex.content) - messages.error(request, '{}: {} ({})'.format( - _('We had trouble communicating with PayPal'), - ex['error_description'], - ex['correlation_id']) - ) - return + # 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() - # Even if the token has been refreshed, calling userinfo() can fail. In this case we just don't - # get the userinfo again and use the payment_paypal_connect_user_id that we already have on file - try: - userinfo = tokeninfo.userinfo() - request.event.settings.payment_paypal_connect_user_id = userinfo.email - except UnauthorizedAccess: - pass - - payee = { - "email": request.event.settings.payment_paypal_connect_user_id, - # If PayPal ever offers a good way to get the MerchantID via the Identifity API, - # we should use it instead of the merchant's eMail-address - # "merchant_id": request.event.settings.payment_paypal_connect_user_id, - } + 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: - payee = {} - - payment = paypalrestsdk.Payment({ - 'header': {'PayPal-Partner-Attribution-Id': 'ramiioSoftwareentwicklung_SP'}, - 'intent': 'sale', - 'payer': { - "payment_method": "paypal", - }, - "redirect_urls": { - "return_url": build_absolute_uri(request.event, 'plugins:paypal:return'), - "cancel_url": build_absolute_uri(request.event, 'plugins:paypal:abort'), - }, - "transactions": [ - { - "item_list": { - "items": [ - { - "name": '{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_obj.order.code - ), - postfix=' {}'.format(self.settings.postfix) if self.settings.postfix else '' - ), - "quantity": 1, - "price": self.format_price(payment_obj.amount), - "currency": payment_obj.order.event.currency - } - ] - }, - "amount": { - "currency": request.event.currency, - "total": self.format_price(payment_obj.amount) - }, - "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_obj.order.code - ), - postfix=' {}'.format(self.settings.postfix) if self.settings.postfix else '' - ), - "payee": payee, - "custom": '{prefix}{slug}-{code}{postfix}'.format( - prefix='{} '.format(self.settings.prefix) if self.settings.prefix else '', - slug=self.event.slug.upper(), - code=payment_obj.order.code, - postfix=' {}'.format(self.settings.postfix) if self.settings.postfix else '' - ), - } - ] - }) - request.session['payment_paypal_payment'] = payment_obj.pk - return self._create_payment(request, payment) - except paypalrestsdk.exceptions.ConnectionError as e: - messages.error(request, _('We had trouble communicating with PayPal')) - logger.exception('Error on creating payment: ' + str(e)) + 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: @@ -734,3 +954,44 @@ class Paypal(BasePaymentProvider): 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:paypal:pay', kwargs={ + 'order': payment.order.code, + 'payment': payment.pk, + 'hash': hashlib.sha1(payment.order.secret.lower().encode()).hexdigest(), + }) diff --git a/src/pretix/plugins/paypal/signals.py b/src/pretix/plugins/paypal/signals.py index 669838fc00..34c7193908 100644 --- a/src/pretix/plugins/paypal/signals.py +++ b/src/pretix/plugins/paypal/signals.py @@ -24,18 +24,27 @@ 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.paypal.payment import PaypalMethod +from pretix.presale.signals import html_head, process_response @receiver(register_payment_providers, dispatch_uid="payment_paypal") def register_payment_provider(sender, **kwargs): - from .payment import Paypal - return Paypal + from .payment import PaypalAPM, PaypalSettingsHolder, PaypalWallet + return [PaypalSettingsHolder, PaypalWallet, PaypalAPM] @receiver(signal=logentry_display, dispatch_uid="paypal_logentry_display") @@ -52,6 +61,7 @@ def pretixcontrol_logentry_display(sender, logentry, **kwargs): '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: @@ -67,15 +77,20 @@ def pretixcontrol_logentry_display(sender, logentry, **kwargs): def register_global_settings(sender, **kwargs): return OrderedDict([ ('payment_paypal_connect_client_id', forms.CharField( - label=_('PayPal Connect: Client ID'), + label=_('PayPal ISU/Connect: Client ID'), required=False, )), ('payment_paypal_connect_secret_key', SecretKeySettingsField( - label=_('PayPal Connect: Secret key'), + 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 Connect Endpoint'), + label=_('PayPal ISU/Connect Endpoint'), initial='live', choices=( ('live', 'Live'), @@ -83,3 +98,73 @@ def register_global_settings(sender, **kwargs): ), )), ]) + + +@receiver(html_head, dispatch_uid="payment_paypal_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:paypal" and url.url_name == "pay") + ): + provider.init_api() + template = get_template('pretixplugins/paypal/presale_head.html') + + ctx = { + 'client_id': provider.client.environment.client_id, + 'merchant_id': provider.client.environment.merchant_id, + '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_paypal_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:paypal" 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/paypal/static/pretixplugins/paypal/pretix-paypal.js b/src/pretix/plugins/paypal/static/pretixplugins/paypal/pretix-paypal.js new file mode 100644 index 0000000000..458345eb33 --- /dev/null +++ b/src/pretix/plugins/paypal/static/pretixplugins/paypal/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/paypal/templates/pretixplugins/paypal/checkout_payment_confirm.html b/src/pretix/plugins/paypal/templates/pretixplugins/paypal/checkout_payment_confirm.html index f10976374d..418f881a9f 100644 --- a/src/pretix/plugins/paypal/templates/pretixplugins/paypal/checkout_payment_confirm.html +++ b/src/pretix/plugins/paypal/templates/pretixplugins/paypal/checkout_payment_confirm.html @@ -1,6 +1,14 @@ {% load i18n %} -

{% blocktrans trimmed %} - The total amount listed above will be withdrawn from your PayPal account after the - confirmation of your purchase. -{% endblocktrans %}

+

+ {% 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/paypal/templates/pretixplugins/paypal/checkout_payment_form.html b/src/pretix/plugins/paypal/templates/pretixplugins/paypal/checkout_payment_form.html index 3ec5898118..6bdb7d82f4 100644 --- a/src/pretix/plugins/paypal/templates/pretixplugins/paypal/checkout_payment_form.html +++ b/src/pretix/plugins/paypal/templates/pretixplugins/paypal/checkout_payment_form.html @@ -1,6 +1,18 @@ {% load i18n %} -

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

+

+ {% 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/paypal/templates/pretixplugins/paypal/control.html b/src/pretix/plugins/paypal/templates/pretixplugins/paypal/control.html index 165c0c9172..7cf80db0d2 100644 --- a/src/pretix/plugins/paypal/templates/pretixplugins/paypal/control.html +++ b/src/pretix/plugins/paypal/templates/pretixplugins/paypal/control.html @@ -2,17 +2,17 @@ {% if payment_info %}
-
{% trans "Payment ID" %}
+
{% trans "Order ID" %}
{{ payment_info.id }}
-
{% trans "Sale ID" %}
-
{{ sale_id|default_if_none:"?" }}
+
{% trans "Status" %}
+
{{ payment_info.status }}
{% trans "Payer" %}
-
{{ payment_info.payer.payer_info.email }}
+
{{ payment_info.payer.email_address }}
{% trans "Last update" %}
-
{{ payment_info.update_time }}
+
{{ payment_info.purchase_units.0.payments.captures.0.update_time }}
{% trans "Total value" %}
-
{{ payment_info.transactions.0.amount.total }}
+
{{ payment_info.purchase_units.0.payments.captures.0.amount.value }}
{% trans "Currency" %}
-
{{ payment_info.transactions.0.amount.currency }}
+
{{ payment_info.purchase_units.0.payments.captures.0.amount.currency_code }}
{% endif %} diff --git a/src/pretix/plugins/paypal/templates/pretixplugins/paypal/control_legacy.html b/src/pretix/plugins/paypal/templates/pretixplugins/paypal/control_legacy.html new file mode 100644 index 0000000000..165c0c9172 --- /dev/null +++ b/src/pretix/plugins/paypal/templates/pretixplugins/paypal/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/paypal/templates/pretixplugins/paypal/pay.html b/src/pretix/plugins/paypal/templates/pretixplugins/paypal/pay.html new file mode 100644 index 0000000000..72d72293da --- /dev/null +++ b/src/pretix/plugins/paypal/templates/pretixplugins/paypal/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/paypal/templates/pretixplugins/paypal/presale_head.html b/src/pretix/plugins/paypal/templates/pretixplugins/paypal/presale_head.html new file mode 100644 index 0000000000..9f67e08d1a --- /dev/null +++ b/src/pretix/plugins/paypal/templates/pretixplugins/paypal/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/paypal/urls.py b/src/pretix/plugins/paypal/urls.py index 361cec20e1..4b4d71dd38 100644 --- a/src/pretix/plugins/paypal/urls.py +++ b/src/pretix/plugins/paypal/urls.py @@ -24,7 +24,8 @@ 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, + PayView, XHRView, abort, isu_disconnect, isu_return, redirect_view, + success, webhook, ) event_patterns = [ @@ -32,17 +33,21 @@ event_patterns = [ 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'), 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'^control/event/(?P[^/]+)/(?P[^/]+)/paypal/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'), - 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..ad76a7a882 100644 --- a/src/pretix/plugins/paypal/views.py +++ b/src/pretix/plugins/paypal/views.py @@ -31,36 +31,73 @@ # 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 -import paypalrestsdk -import paypalrestsdk.exceptions from django.contrib import messages from django.core import signing from django.db.models import Sum -from django.http import HttpResponse, HttpResponseBadRequest +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 paypalrestsdk.openid_connect import Tokeninfo +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.paypal.client.customer.partners_merchantintegrations_get_request import ( + PartnersMerchantIntegrationsGetRequest, +) from pretix.plugins.paypal.models import ReferencedPayPalObject -from pretix.plugins.paypal.payment import Paypal +from pretix.plugins.paypal.payment import PaypalMethod, PaypalMethod as Paypal +from pretix.presale.views import get_cart, get_cart_total logger = logging.getLogger('pretix.plugins.paypal') +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') @@ -76,40 +113,136 @@ def redirect_view(request, *args, **kwargs): 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/paypal/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() -def oauth_return(request, *args, **kwargs): - if 'payment_paypal_oauth_event' not in request.session: +@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_oauth_event']) + event = get_object_or_404(Event, pk=request.session['payment_paypal_isu_event']) + gs = GlobalSettingsObject() 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') + 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: - messages.success(request, - _('Your PayPal account is now connected to pretix. You can change the settings in ' - 'detail below.')) + 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_connect_refresh_token = tokeninfo['refresh_token'] - event.settings.payment_paypal_connect_user_id = userinfo.email + 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' + 'provider': 'paypal_settings' })) def success(request, *args, **kwargs): - pid = request.GET.get('paymentId') token = request.GET.get('token') payer = request.GET.get('PayerID') request.session['payment_paypal_token'] = token @@ -124,7 +257,7 @@ def success(request, *args, **kwargs): else: payment = None - if pid == request.session.get('payment_paypal_id', None): + if request.session.get('payment_paypal_id', None): if payment: prov = Paypal(request.event) try: @@ -178,18 +311,20 @@ def webhook(request, *args, **kwargs): # 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 ('sale', 'refund'): + + if event_json['resource_type'] not in ["checkout-order", "refund", "capture"]: return HttpResponse("Not interested in this resource type", status=200) - if event_json['resource_type'] == 'sale': - saleid = event_json['resource']['id'] + # 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: - saleid = event_json['resource']['sale_id'] + payloadid = event_json['resource']['id'] try: - refs = [saleid] - if event_json['resource'].get('parent_payment'): - refs.append(event_json['resource'].get('parent_payment')) + 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').get( reference__in=refs @@ -206,8 +341,10 @@ def webhook(request, *args, **kwargs): prov.init_api() try: - sale = paypalrestsdk.Sale.find(saleid) - except paypalrestsdk.exceptions.ConnectionError: + 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) @@ -218,47 +355,58 @@ def webhook(request, *args, **kwargs): info__icontains=sale['id']) payment = None for p in payments: - payment_info = p.info_data - for res in payment_info['transactions'][0]['related_resources']: - for k, v in res.items(): - if k == 'sale' and v['id'] == sale['id']: - payment = p - break + # 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['state'] in ('partially_refunded', 'refunded'): + if payment.state == OrderPayment.PAYMENT_STATE_CONFIRMED and sale['status'] in ('PARTIALLY_REFUNDED', 'REFUNDED', 'COMPLETED'): if event_json['resource_type'] == 'refund': try: - refund = paypalrestsdk.Refund.find(event_json['resource']['id']) - except paypalrestsdk.exceptions.ConnectionError: + 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']['total'])), - info=json.dumps(refund.to_dict() if not isinstance(refund, dict) else 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['state'] == 'completed': + OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT) and refund['status'] == 'COMPLETED': known_refunds.get(refund['id']).done() - if 'total_refunded_amount' in refund: + 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['total_refunded_amount']['value']) + 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['state'] == 'refunded': + 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) @@ -269,7 +417,8 @@ def webhook(request, *args, **kwargs): 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['state'] == 'completed': + OrderPayment.PAYMENT_STATE_CANCELED, OrderPayment.PAYMENT_STATE_FAILED) \ + and sale['status'] == 'COMPLETED': try: payment.confirm() except Quota.QuotaExceededException: @@ -280,14 +429,24 @@ def webhook(request, *args, **kwargs): @event_permission_required('can_change_event_settings') @require_POST -def oauth_disconnect(request, **kwargs): +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.')) return redirect(reverse('control:event.settings.payment.provider', kwargs={ 'organizer': request.event.organizer.slug, 'event': request.event.slug, - 'provider': 'paypal' + 'provider': 'paypal_settings' })) + + +def get_link(links, rel): + for link in links: + if link['rel'] == rel: + return link + + return None diff --git a/src/setup.py b/src/setup.py index 4c4cc60ede..3f92626c8a 100644 --- a/src/setup.py +++ b/src/setup.py @@ -203,7 +203,8 @@ setup( 'oauthlib==3.1.*', '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/plugins/paypal/test_checkout.py b/src/tests/plugins/paypal/test_checkout.py index 0dd923a469..0846ffa82b 100644 --- a/src/tests/plugins/paypal/test_checkout.py +++ b/src/tests/plugins/paypal/test_checkout.py @@ -67,15 +67,55 @@ def env(client): 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 create_payment(self, request, payment): - assert payment['intent'] == 'sale' - assert payment['transactions'][0]['amount']['currency'] == 'EUR' - assert payment['transactions'][0]['amount']['total'] == '26.00' - create_payment.called = True - return 'https://approve.url' - monkeypatch.setattr("pretix.plugins.paypal.payment.Paypal._create_payment", create_payment) + 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.paypal.payment.PaypalMethod.init_api", init_api) client, ticket = env session_key = get_cart_session_key(client, ticket.event) @@ -87,7 +127,15 @@ def test_payment(env, monkeypatch): 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', + 'payment_paypal_wallet_oid': '04F89033701558004', + 'payment_paypal_wallet_payer': 'Q739JNKWH67HE', }) - assert response['Location'] == 'https://approve.url' + print(response.content.decode()) + assert response['Location'] == '/ccc/30c3/checkout/confirm/' diff --git a/src/tests/plugins/paypal/test_settings.py b/src/tests/plugins/paypal/test_settings.py index d9a4729d12..8314272350 100644 --- a/src/tests/plugins/paypal/test_settings.py +++ b/src/tests/plugins/paypal/test_settings.py @@ -61,7 +61,7 @@ def env(client): @pytest.mark.django_db def test_settings(env): client, event = env - response = client.get('/control/event/%s/%s/settings/payment/paypal' % (event.organizer.slug, event.slug), + 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/paypal/test_webhook.py b/src/tests/plugins/paypal/test_webhook.py index 7395f619ff..3b1571cd86 100644 --- a/src/tests/plugins/paypal/test_webhook.py +++ b/src/tests/plugins/paypal/test_webhook.py @@ -48,190 +48,348 @@ def env(): code='FOOBAR', event=event, email='dummy@dummy.test', status=Order.STATUS_PAID, datetime=now(), expires=now() + timedelta(days=10), - total=Decimal('13.37'), + total=Decimal('43.59'), ) o1.payments.create( amount=o1.total, provider='paypal', state=OrderPayment.PAYMENT_STATE_CONFIRMED, info=json.dumps({ - "id": "PAY-5YK922393D847794YKER7MUI", - "create_time": "2013-02-19T22:01:53Z", - "update_time": "2013-02-19T22:01:55Z", - "state": "approved", - "intent": "sale", - "payer": { - "payment_method": "credit_card", - "funding_instruments": [ - { - "credit_card": { - "type": "mastercard", - "number": "xxxxxxxxxxxx5559", - "expire_month": 2, - "expire_year": 2018, - "first_name": "Betsy", - "last_name": "Buyer" - } - } - ] - }, - "transactions": [ + "id": "806440346Y391300T", + "status": "COMPLETED", + "purchase_units": [ { - "amount": { - "total": "7.47", - "currency": "USD", - "details": { - "subtotal": "7.47" + "reference_id": "default", + "shipping": { + "name": { + "full_name": "test buyer" } }, - "description": "This is the payment transaction description.", - "note_to_payer": "Contact us for any questions on your order.", - "related_resources": [ - { - "sale": { - "id": "36C38912MN9658832", - "create_time": "2013-02-19T22:01:53Z", - "update_time": "2013-02-19T22:01:55Z", - "state": "completed", + "payments": { + "captures": [ + { + "id": "22A4162004478570J", + "status": "COMPLETED", "amount": { - "total": "7.47", - "currency": "USD" + "currency_code": "EUR", + "value": "43.59" }, - "protection_eligibility": "ELIGIBLE", - "protection_eligibility_type": "ITEM_NOT_RECEIVED_ELIGIBLE", - "transaction_fee": { - "value": "1.75", - "currency": "USD" + "final_capture": True, + "disbursement_mode": "INSTANT", + "seller_protection": { + "status": "ELIGIBLE", + "dispute_categories": [ + "ITEM_NOT_RECEIVED", + "UNAUTHORIZED_TRANSACTION" + ] }, - "parent_payment": "PAY-5YK922393D847794YKER7MUI", + "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.paypal.com/v1/payments/sale/36C38912MN9658832", + "href": "https://api.sandbox.paypal.com/v2/payments/captures/22A4162004478570J", "rel": "self", "method": "GET" }, { - "href": "https://api.paypal.com/v1/payments/sale/36C38912MN9658832/refund", + "href": "https://api.sandbox.paypal.com/v2/payments/captures/22A4162004478570J/refund", "rel": "refund", "method": "POST" }, { - "href": - "https://api.paypal.com/v1/payments/payment/PAY-5YK922393D847794YKER7MUI", - "rel": "parent_payment", + "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.paypal.com/v1/payments/payment/PAY-5YK922393D847794YKER7MUI", + "href": "https://api.sandbox.paypal.com/v2/checkout/orders/806440346Y391300T", "rel": "self", "method": "GET" } ] - }) ) return event, o1 -def get_test_charge(order: Order): +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": "36C38912MN9658832", - "create_time": "2013-02-19T22:01:53Z", - "update_time": "2013-02-19T22:01:55Z", - "state": "completed", + "id": "1YK122615V244890X", "amount": { - "total": "7.47", - "currency": "USD" + "currency_code": "EUR", + "value": "43.59" }, - "protection_eligibility": "ELIGIBLE", - "protection_eligibility_type": "ITEM_NOT_RECEIVED_ELIGIBLE,UNAUTHORIZED_PAYMENT_ELIGIBLE", - "transaction_fee": { - "value": "1.75", - "currency": "USD" + "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" + } }, - "parent_payment": "PAY-5YK922393D847794YKER7MUI", + "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.paypal.com/v1/payments/sale/36C38912MN9658832", + "href": "https://api.sandbox.paypal.com/v2/payments/refunds/1YK122615V244890X", "rel": "self", "method": "GET" }, { - "href": "https://api.paypal.com/v1/payments/sale/36C38912MN9658832/refund", - "rel": "refund", - "method": "POST" - }, - { - "href": "https://api.paypal.com/v1/payments/payment/PAY-5YK922393D847794YKER7MUI", - "rel": "parent_payment", + "href": "https://api.sandbox.paypal.com/v2/payments/captures/22A4162004478570J", + "rel": "up", "method": "GET" } ] } -def get_test_refund(order: Order): - return { - 'refund_from_received_amount': {'value': '13.30', 'currency': 'EUR'}, - 'amount': {'total': '13.37', 'currency': 'EUR'}, - 'sale_id': '1G495778AR8401726', - 'update_time': '2018-07-24T07:50:07Z', - 'total_refunded_amount': {'value': '13.37', 'currency': 'EUR'}, - 'refund_reason_code': 'REFUND', - 'invoice_number': 'Test', - 'parent_payment': 'PAY-0UB50445HE155450FLNLNMUY', - 'state': 'completed', - 'create_time': '2018-07-24T07:50:07Z', - 'refund_from_transaction_fee': {'value': '0.07', 'currency': 'EUR'}, - 'id': '93M41501U3542574L', - 'refund_to_payer': {'value': '13.37', 'currency': 'EUR'}, - 'links': [ - {'method': 'GET', 'rel': 'self', - 'href': 'https://api.sandbox.paypal.com/v1/payments/refund/93M41501U3542574L'}, - {'method': 'GET', - 'rel': 'parent_payment', - 'href': 'https://api.sandbox.paypal.com/v1/payments/payment/PAY-0UB50445HE155450FLNLNMUY'}, - {'method': 'GET', 'rel': 'sale', - 'href': 'https://api.sandbox.paypal.com/v1/payments/sale/1G495778AR8401726'} - ] - } +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): - 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) + order = env[1] + pp_order = get_test_order() + monkeypatch.setattr("paypalcheckoutsdk.orders.OrdersGetRequest", lambda *args: pp_order) + monkeypatch.setattr("pretix.plugins.paypal.payment.PaypalMethod.init_api", init_api) + + with scopes_disabled(): + ReferencedPayPalObject.objects.create(order=order, payment=order.payments.first(), + reference="806440346Y391300T") 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", + "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": { - "amount": { - "total": "-0.01", - "currency": "USD" + "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" + } }, - "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" + "status": "COMPLETED" }, - "links": [], - "event_version": "1.0" + "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') @@ -248,33 +406,133 @@ def test_webhook_global(env, client, monkeypatch): 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) - ReferencedPayPalObject.objects.create(order=order, reference="PAY-5YK922393D847794YKER7MUI") + pp_order = get_test_order() + monkeypatch.setattr("paypalcheckoutsdk.orders.OrdersGetRequest", lambda *args: pp_order) + monkeypatch.setattr("pretix.plugins.paypal.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-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", + "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": { - "amount": { - "total": "-0.01", - "currency": "USD" + "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" + } }, - "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" + "status": "COMPLETED" }, - "links": [], - "event_version": "1.0" + "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') @@ -290,32 +548,93 @@ def test_webhook_mark_paid(env, client, monkeypatch): 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) + pp_order = get_test_order() + monkeypatch.setattr("paypalcheckoutsdk.orders.OrdersGetRequest", lambda *args: pp_order) + monkeypatch.setattr("pretix.plugins.paypal.payment.PaypalMethod.init_api", init_api) + with scopes_disabled(): + ReferencedPayPalObject.objects.create(order=order, payment=order.payments.first(), + reference="806440346Y391300T") 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", + "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": { - "total": "-0.01", - "currency": "USD" + "value": "43.59", + "currency_code": "EUR" }, - "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" + "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" }, - "links": [], - "event_version": "1.0" + "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') @@ -325,43 +644,82 @@ def test_webhook_mark_paid(env, client, monkeypatch): @pytest.mark.django_db def test_webhook_refund1(env, client, monkeypatch): - charge = get_test_charge(env[1]) - charge['state'] = 'refunded' - refund = get_test_refund(env[1]) + order = env[1] + pp_order = get_test_order() + pp_refund = get_test_refund() - 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) + monkeypatch.setattr("paypalcheckoutsdk.orders.OrdersGetRequest", lambda *args: pp_order) + monkeypatch.setattr("paypalcheckoutsdk.payments.RefundsGetRequest", lambda *args: pp_refund) + monkeypatch.setattr("pretix.plugins.paypal.payment.PaypalMethod.init_api", init_api) + with scopes_disabled(): + ReferencedPayPalObject.objects.create(order=order, payment=order.payments.first(), + reference="22A4162004478570J") client.post('/dummy/dummy/paypal/webhook/', json.dumps( { - # Sample obtained in a sandbox webhook - "id": "WH-9K829080KA1622327-31011919VC6498738", - "create_time": "2017-01-15T20:15:36Z", + "id": "WH-5LJ60612747357339-66248625WA926672S", + "create_time": "2022-04-28T14:51:00.318Z", "resource_type": "refund", - "event_type": "PAYMENT.SALE.REFUNDED", - "summary": "A EUR 255.41 EUR sale payment was refunded", + "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": { - "total": "255.41", - "currency": "EUR" + "value": "43.59", + "currency_code": "EUR" }, - "id": "75S46770PP192124D", - "parent_payment": "PAY-5YK922393D847794YKER7MUI", - "update_time": "2017-01-15T20:15:06Z", - "create_time": "2017-01-15T20:14:29Z", - "state": "completed", - "links": [], - "refund_to_payer": { - "value": "255.41", - "currency": "EUR" - }, - "invoice_number": "", - "refund_reason_code": "REFUND", - "sale_id": "9T0916710M1105906" + "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" }, - "links": [], - "event_version": "1.0" + "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') @@ -380,37 +738,82 @@ def test_webhook_refund1(env, client, monkeypatch): @pytest.mark.django_db def test_webhook_refund2(env, client, monkeypatch): - charge = get_test_charge(env[1]) - charge['state'] = 'refunded' - refund = get_test_refund(env[1]) + order = env[1] + pp_order = get_test_order() + pp_refund = get_test_refund() - 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) + monkeypatch.setattr("paypalcheckoutsdk.orders.OrdersGetRequest", lambda *args: pp_order) + monkeypatch.setattr("paypalcheckoutsdk.payments.RefundsGetRequest", lambda *args: pp_refund) + monkeypatch.setattr("pretix.plugins.paypal.payment.PaypalMethod.init_api", init_api) + with scopes_disabled(): + ReferencedPayPalObject.objects.create(order=order, payment=order.payments.first(), + reference="22A4162004478570J") client.post('/dummy/dummy/paypal/webhook/', json.dumps( { - # Sample obtained in the webhook simulator - "id": "WH-2N242548W9943490U-1JU23391CS4765624", - "create_time": "2014-10-31T15:42:24Z", + "id": "WH-7FL378472F5218625-6WC87835CR8751809", + "create_time": "2022-04-28T14:56:08.160Z", "resource_type": "refund", - "event_type": "PAYMENT.SALE.REFUNDED", - "summary": "A 0.01 USD sale payment was refunded", + "event_type": "PAYMENT.CAPTURE.REFUNDED", + "summary": "A EUR 43.59 EUR capture payment was refunded", "resource": { - "amount": { - "total": "-0.01", - "currency": "USD" + "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" + } }, - "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" + "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" }, - "links": [], - "event_version": "1.0" + "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')