diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index 9a14fcecc..a34e128a7 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 6e6d8fedd..d9f2921f4 100644 --- a/src/pretix/plugins/paypal/apps.py +++ b/src/pretix/plugins/paypal/apps.py @@ -33,6 +33,7 @@ # 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 @@ -54,3 +55,12 @@ 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 deleted file mode 100644 index 2be81982a..000000000 --- a/src/pretix/plugins/paypal/client/core/environment.py +++ /dev/null @@ -1,66 +0,0 @@ -# -# 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 deleted file mode 100644 index 1e78f55f9..000000000 --- a/src/pretix/plugins/paypal/client/core/paypal_http_client.py +++ /dev/null @@ -1,70 +0,0 @@ -# -# 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 deleted file mode 100644 index b0ca081ba..000000000 --- a/src/pretix/plugins/paypal/client/customer/partner_referral_create_request.py +++ /dev/null @@ -1,38 +0,0 @@ -# -# 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 deleted file mode 100644 index 68ce135e2..000000000 --- a/src/pretix/plugins/paypal/client/customer/partners_merchantintegrations_get_request.py +++ /dev/null @@ -1,43 +0,0 @@ -# -# 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 6ad181f45..3d9cf86a1 100644 --- a/src/pretix/plugins/paypal/payment.py +++ b/src/pretix/plugins/paypal/payment.py @@ -31,49 +31,34 @@ # 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 paypalcheckoutsdk.orders import ( - OrdersCaptureRequest, OrdersCreateRequest, OrdersGetRequest, - OrdersPatchRequest, -) -from paypalcheckoutsdk.payments import CapturesRefundRequest, RefundsGetRequest +from paypalrestsdk.exceptions import BadRequest, UnauthorizedAccess +from paypalrestsdk.openid_connect import Tokeninfo -from pretix import settings from pretix.base.decimal import round_decimal -from pretix.base.forms.questions import guess_country from pretix.base.models import Event, Order, OrderPayment, OrderRefund, Quota from pretix.base.payment import BasePaymentProvider, PaymentException from pretix.base.services.mail import SendMailException from pretix.base.settings import SettingsSandbox from pretix.helpers.urls import build_absolute_uri as build_global_uri -from pretix.multidomain.urlreverse import build_absolute_uri, eventreverse -from pretix.plugins.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.multidomain.urlreverse import build_absolute_uri from pretix.plugins.paypal.models import ReferencedPayPalObject logger = logging.getLogger('pretix.plugins.paypal') @@ -84,21 +69,32 @@ SUPPORTED_CURRENCIES = ['AUD', 'BRL', 'CAD', 'CZK', 'DKK', 'EUR', 'HKD', 'HUF', LOCAL_ONLY_CURRENCIES = ['INR'] -class PaypalSettingsHolder(BasePaymentProvider): - identifier = 'paypal_settings' +class Paypal(BasePaymentProvider): + identifier = 'paypal' verbose_name = _('PayPal') - is_enabled = False - is_meta = True - payment_form_fields = OrderedDict([]) + 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): - # 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_client_id and not self.settings.secret: + # PayPal connect if self.settings.connect_user_id: fields = [ ('connect_user_id', @@ -107,17 +103,8 @@ class PaypalSettingsHolder(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', @@ -147,68 +134,6 @@ class PaypalSettingsHolder(BasePaymentProvider): )), ] - methods = [ - ('method_wallet', - forms.BooleanField( - label=_('PayPal'), - required=False, - help_text=_( - 'Even if a customer chooses an Alternative Payment Method, they will always have the option to ' - 'revert back to paying with their PayPal account. For this reason, this payment method is always ' - 'active.' - ), - disabled=True, - )), - ('method_apm', - forms.BooleanField( - label=_('Alternative Payment Methods'), - help_text=_( - 'In addition to payments through a PayPal account, you can also offer your customers the option ' - 'to pay with credit cards and other, local payment methods such as SOFORT, giropay, iDEAL, and ' - 'many more - even when they do not have a PayPal account. Eligible payment methods will be ' - 'determined based on the shoppers location. For German merchants, this is the direct successor ' - 'of PayPal Plus.' - ), - required=False, - widget=forms.CheckboxInput( - attrs={ - 'data-checkbox-dependency': '#id_payment_paypal_method_wallet', - } - ) - )), - ('disable_method_sepa', - forms.BooleanField( - label=_('Disable SEPA Direct Debit'), - help_text=_( - 'While most payment methods cannot be recalled by a customer without outlining their exact grief ' - 'with the merchants, SEPA Direct Debit can be recalled with the press of a button. For that ' - 'reason - and depending on the nature of your event - you might want to disabled the option of ' - 'SEPA Direct Debit payments in order to reduce the risk of costly chargebacks.' - ), - required=False, - widget=forms.CheckboxInput( - attrs={ - 'data-checkbox-dependency': '#id_payment_paypal_method_apm', - } - ) - )), - ('enable_method_paylater', - forms.BooleanField( - label=_('Enable Buy Now Pay Later'), - help_text=_( - 'Offer your customers the possibility to buy now (up to a certain limit) and pay in multiple installments ' - 'or within 30 days. You, as the merchant, are getting your money right away.' - ), - required=False, - widget=forms.CheckboxInput( - attrs={ - 'data-checkbox-dependency': '#id_payment_paypal_method_apm', - } - ) - )), - - ] - extra_fields = [ ('prefix', forms.CharField( @@ -226,21 +151,8 @@ class PaypalSettingsHolder(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 + methods + extra_fields + list(super().settings_form_fields.items()) + fields + extra_fields + list(super().settings_form_fields.items()) ) d.move_to_end('prefix') @@ -248,28 +160,32 @@ class PaypalSettingsHolder(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 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) + if self.settings.connect_client_id and not self.settings.secret: + # Use PayPal connect + if not self.settings.connect_user_id: settings_content = ( "

{}

" - "{}" + "{}" ).format( _('To accept payments via PayPal, you will need an account at PayPal. By clicking on the ' 'following button, you can either create a new PayPal account connect pretix to an existing ' 'one.'), - isu_referral_url, - 'disabled' if not isu_referral_url else '', + self.get_connect_url(request), _('Connect with {icon} PayPal').format(icon='') ) else: settings_content = ( "" ).format( - reverse('plugins:paypal:isu.disconnect', kwargs={ + reverse('plugins:paypal:oauth.disconnect', kwargs={ 'organizer': self.event.organizer.slug, 'event': self.event.slug, }), @@ -302,182 +218,114 @@ class PaypalSettingsHolder(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: - 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 + 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'))) else: - if 'sandbox' in self.settings.get('endpoint'): - env = SandboxEnvironment( - client_id=self.settings.get('client_id'), - client_secret=self.settings.get('secret'), - merchant_id=None, - partner_id=self.BN - ) - else: - env = LiveEnvironment( - client_id=self.settings.get('client_id'), - client_secret=self.settings.get('secret'), - merchant_id=None, - partner_id=self.BN - ) - - self.client = PayPalHttpClient(env) + 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')) def payment_is_valid_session(self, request): - return request.session.get('payment_paypal_oid', '') != '' + return (request.session.get('payment_paypal_id', '') != '' + and request.session.get('payment_paypal_payer', '') != '') 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, - 'method': self.method, - 'xhr': eventreverse(self.event, 'plugins:paypal:xhr', kwargs=build_kwargs()) - } + ctx = {'request': request, 'event': self.event, 'settings': self.settings} return template.render(ctx) def checkout_prepare(self, request, cart): - paypal_order_id = request.POST.get('payment_paypal_{}_oid'.format(self.method), 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 OID has been previously generated through XHR and onApprove() has fired - if paypal_order_id and paypal_order_id == request.session.get('payment_paypal_oid', None): - self.init_api() + try: + 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 - 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 + # 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, + } else: - if response.result.status == 'APPROVED': - return True - messages.warning(request, _('Something went wrong when requesting the payment status. Please try again.')) - return False - # onApprove has fired, but we don't have a matching OID in the session - manipulation/something went wrong. - elif paypal_order_id: - messages.warning(request, _('We had trouble communicating with PayPal')) - return False - else: - # We don't have an XHR-generated OID, nor a onApprove-fired OID. - # Probably no active JavaScript; this won't work - messages.warning(request, _('You may need to enable JavaScript for PayPal payments.')) - return False + 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)) def format_price(self, value): return str(round_decimal(value, self.event.currency, { @@ -511,92 +359,26 @@ class PaypalMethod(BasePaymentProvider): def abort_pending_allowed(self): return False - def _create_paypal_order(self, request, payment=None, cart=None): - self.init_api() - kwargs = {} - if request.resolver_match and 'cart_namespace' in request.resolver_match.kwargs: - kwargs['cart_namespace'] = request.resolver_match.kwargs['cart_namespace'] - - # 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.exception('PayPal OrdersCreateRequest: {}'.format(str(e))) - else: - if response.result.status not in ('CREATED', 'PAYER_ACTION_REQUIRED'): + 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(paymentreq)) + logger.error('Invalid payment state: ' + str(payment)) return - - request.session['payment_paypal_oid'] = response.result.id - return response.result + 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) + else: + messages.error(request, _('We had trouble communicating with PayPal')) + logger.error('Error on creating payment: ' + str(payment.error)) def checkout_confirm_render(self, request) -> str: """ @@ -608,109 +390,104 @@ class PaypalMethod(BasePaymentProvider): return template.render(ctx) def execute_payment(self, request: HttpRequest, payment: OrderPayment): - try: - if request.session.get('payment_paypal_oid', '') == '': - raise PaymentException(_('We were unable to process your payment. See below for details on how to ' - 'proceed.')) + 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.')) - self.init_api() + 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 '' + ), + } + ]) try: - req = OrdersGetRequest(request.session.get('payment_paypal_oid')) - response = self.client.execute(req) - except IOError as e: - logger.exception('PayPal OrdersGetRequest: {}'.format(str(e))) - raise PaymentException(_('We had trouble communicating with PayPal')) - else: - pp_captured_order = response.result + 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)) - 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': + for trans in payment.transactions: + for rr in trans.related_resources: + if hasattr(rr, 'sale') and rr.sale: + if rr.sale.state == 'pending': messages.warning(request, _('PayPal has not yet approved the payment. We will inform you as ' 'soon as the payment completed.')) - payment.info = json.dumps(pp_captured_order.dict()) - payment.state = OrderPayment.PAYMENT_STATE_PENDING - payment.save() + payment_obj.info = json.dumps(payment.to_dict()) + payment_obj.state = OrderPayment.PAYMENT_STATE_PENDING + payment_obj.save() return - payment.refresh_from_db() + 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 - if pp_captured_order.status != 'COMPLETED': - payment.fail(info=pp_captured_order.dict()) - logger.error('Invalid state: %s' % str(pp_captured_order)) - raise PaymentException( - _('We were unable to process your payment. See below for details on how to proceed.') - ) + if payment.state != '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 payment.state == OrderPayment.PAYMENT_STATE_CONFIRMED: - logger.warning('PayPal success event even though order is already marked as paid') - return + if payment_obj.state == OrderPayment.PAYMENT_STATE_CONFIRMED: + logger.warning('PayPal success event even though order is already marked as paid') + return - try: - payment.info = json.dumps(pp_captured_order.dict()) - payment.save(update_fields=['info']) - payment.confirm() - except Quota.QuotaExceededException as e: - raise PaymentException(str(e)) + 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)) - except SendMailException: - messages.warning(request, _('There was an error sending the confirmation mail.')) - finally: - del request.session['payment_paypal_oid'] + except SendMailException: + messages.warning(request, _('There was an error sending the confirmation mail.')) + return None def payment_pending_render(self, request, payment) -> str: retry = True @@ -726,77 +503,39 @@ class PaypalMethod(BasePaymentProvider): def matching_id(self, payment: OrderPayment): sale_id = None - - # Legacy PayPal info-data - if 'purchase_units' not in payment.info_data: - for trans in payment.info_data.get('transactions', []): - for res in trans.get('related_resources', []): - if 'sale' in res and 'id' in res['sale']: - sale_id = res['sale']['id'] - else: - for trans in payment.info_data.get('purchase_units', []): - for res in trans.get('payments', {}).get('captures', []): - sale_id = res['id'] - + 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 sale_id or payment.info_data.get('id', None) def api_payment_details(self, payment: OrderPayment): sale_id = None - - # Legacy PayPal info-data - if 'purchase_units' not in payment.info_data: - for trans in payment.info_data.get('transactions', []): - for res in trans.get('related_resources', []): - if 'sale' in res and 'id' in res['sale']: - sale_id = res['sale']['id'] - - return { - "payer_email": payment.info_data.get('payer', {}).get('payer_info', {}).get('email'), - "payer_id": payment.info_data.get('payer', {}).get('payer_info', {}).get('payer_id'), - "cart_id": payment.info_data.get('cart', None), - "payment_id": payment.info_data.get('id', None), - "sale_id": sale_id, - } - else: - for trans in payment.info_data.get('purchase_units', []): - for res in trans.get('payments', {}).get('captures', []): - sale_id = res['id'] - - return { - "payer_email": payment.info_data.get('payer', {}).get('email_address'), - "payer_id": payment.info_data.get('payer', {}).get('payer_id'), - "cart_id": payment.info_data.get('id', None), - "payment_id": sale_id, - "sale_id": sale_id, - } + 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, + } def payment_control_render(self, request: HttpRequest, payment: OrderPayment): - # Legacy PayPal info-data - if 'purchase_units' not in payment.info_data: - template = get_template('pretixplugins/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} - + 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} return template.render(ctx) def payment_control_render_short(self, payment: OrderPayment) -> str: - # Legacy PayPal info-data - if 'purchase_units' not in payment.info_data: - return payment.info_data.get('payer', {}).get('payer_info', {}).get('email', '') - else: - return '{} / {}'.format( - payment.info_data.get('id', ''), - payment.info_data.get('payer', {}).get('email_address', '') - ) + return payment.info_data.get('payer', {}).get('payer_info', {}).get('email', '') def payment_partial_refund_supported(self, payment: OrderPayment): # Paypal refunds are possible for 180 days after purchase: @@ -810,95 +549,136 @@ class PaypalMethod(BasePaymentProvider): self.init_api() try: - pp_payment = None - payment_info_data = None - # Legacy PayPal - get up to date info data first - if "purchase_units" not in refund.payment.info_data: - req = OrdersGetRequest(refund.payment.info_data['cart']) - response = self.client.execute(req) - payment_info_data = response.result.dict() - else: - payment_info_data = refund.payment.info_data - - for res in payment_info_data['purchase_units'][0]['payments']['captures']: - if res['status'] in ['COMPLETED', 'PARTIALLY_REFUNDED']: - pp_payment = res['id'] - break - - if not pp_payment: - req = OrdersGetRequest(payment_info_data['id']) - response = self.client.execute(req) - for res in response.result.purchase_units[0].payments.captures: - if res['status'] in ['COMPLETED', 'PARTIALLY_REFUNDED']: - pp_payment = res.id + 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']) break - req = CapturesRefundRequest(pp_payment) - req.request_body({ + 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({ "amount": { - "value": self.format_price(refund.amount), - "currency_code": refund.order.event.currency + "total": self.format_price(refund.amount), + "currency": refund.order.event.currency } }) - response = self.client.execute(req) - except IOError as e: + except paypalrestsdk.exceptions.ConnectionError as e: refund.order.log_action('pretix.event.order.refund.failed', { 'local_id': refund.local_id, 'provider': refund.provider, 'error': str(e) }) - logger.error('execute_refund: {}'.format(str(e))) raise PaymentException(_('Refunding the amount via PayPal failed: {}').format(str(e))) - - refund.info = json.dumps(response.result.dict()) - refund.save(update_fields=['info']) - - req = RefundsGetRequest(response.result.id) - response = self.client.execute(req) - refund.info = json.dumps(response.result.dict()) - refund.save(update_fields=['info']) - - if response.result.status == 'COMPLETED': - refund.done() - elif response.result.status == 'PENDING': - refund.state = OrderRefund.REFUND_STATE_TRANSIT - refund.save(update_fields=['state']) - else: + if not pp_refund.success(): refund.order.log_action('pretix.event.order.refund.failed', { 'local_id': refund.local_id, 'provider': refund.provider, - 'error': str(response.result.status_details.reason) + 'error': str(pp_refund.error) }) - raise PaymentException(_('Refunding the amount via PayPal failed: {}').format(response.result.status_details.reason)) - - def payment_prepare(self, request, payment): - paypal_order_id = request.POST.get('payment_paypal_{}_oid'.format(self.method), None) - - # PayPal OID has been previously generated through XHR and onApprove() has fired - if paypal_order_id and paypal_order_id == request.session.get('payment_paypal_oid', None): - self.init_api() - - try: - req = OrdersGetRequest(paypal_order_id) - response = self.client.execute(req) - except IOError as e: - messages.warning(request, _('We had trouble communicating with PayPal')) - logger.exception('PayPal OrdersGetRequest: {}'.format(str(e))) - return False - else: - if response.result.status == 'APPROVED': - return True - messages.warning(request, _('Something went wrong when requesting the payment status. Please try again.')) - return False - # onApprove has fired, but we don't have a matching OID in the session - manipulation/something went wrong. - elif paypal_order_id: - messages.warning(request, _('We had trouble communicating with PayPal')) - return False + raise PaymentException(_('Refunding the amount via PayPal failed: {}').format(pp_refund.error)) 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 + 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() + + def payment_prepare(self, request, payment_obj): + self.init_api() + + 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 + + # 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, + } + 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)) def shred_payment_info(self, obj): if obj.info: @@ -954,44 +734,3 @@ class PaypalMethod(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 34c719390..669838fc0 100644 --- a/src/pretix/plugins/paypal/signals.py +++ b/src/pretix/plugins/paypal/signals.py @@ -24,27 +24,18 @@ 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 PaypalAPM, PaypalSettingsHolder, PaypalWallet - return [PaypalSettingsHolder, PaypalWallet, PaypalAPM] + from .payment import Paypal + return Paypal @receiver(signal=logentry_display, dispatch_uid="paypal_logentry_display") @@ -61,7 +52,6 @@ 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: @@ -77,20 +67,15 @@ def pretixcontrol_logentry_display(sender, logentry, **kwargs): def register_global_settings(sender, **kwargs): return OrderedDict([ ('payment_paypal_connect_client_id', forms.CharField( - label=_('PayPal ISU/Connect: Client ID'), + label=_('PayPal Connect: Client ID'), required=False, )), ('payment_paypal_connect_secret_key', SecretKeySettingsField( - label=_('PayPal ISU/Connect: Secret key'), - required=False, - )), - ('payment_paypal_connect_partner_merchant_id', forms.CharField( - label=_('PayPal ISU/Connect: Partner Merchant ID'), - help_text=_('This is not the BN-code, but rather the ID of the merchant account which holds branding information for ISU.'), + label=_('PayPal Connect: Secret key'), required=False, )), ('payment_paypal_connect_endpoint', forms.ChoiceField( - label=_('PayPal ISU/Connect Endpoint'), + label=_('PayPal Connect Endpoint'), initial='live', choices=( ('live', 'Live'), @@ -98,73 +83,3 @@ 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 deleted file mode 100644 index 458345eb3..000000000 --- a/src/pretix/plugins/paypal/static/pretixplugins/paypal/pretix-paypal.js +++ /dev/null @@ -1,281 +0,0 @@ -/*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 418f881a9..f10976374 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,14 +1,6 @@ {% load i18n %} -

- {% if method == "wallet" %} - {% blocktrans trimmed %} - The total amount listed above will be withdrawn from your PayPal account after the - confirmation of your purchase. - {% endblocktrans %} - {% else %} - {% blocktrans trimmed %} - After placing your order, you will be able to select your desired payment method, including PayPal. - {% endblocktrans %} - {% endif %} -

+

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

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 6bdb7d82f..3ec589811 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,18 +1,6 @@ {% load i18n %} -

- {% if method == "wallet" %} - {% blocktrans trimmed %} - Please click the "Pay with PayPal" button below to start your payment. - {% endblocktrans %} - {% else %} - {% blocktrans trimmed %} - After you clicked continue, we will redirect you to PayPal to fill in your payment - details. You will then be redirected back here to review and confirm your order. - {% endblocktrans %} - {% endif %} -

- - - - \ No newline at end of file +

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

diff --git a/src/pretix/plugins/paypal/templates/pretixplugins/paypal/control.html b/src/pretix/plugins/paypal/templates/pretixplugins/paypal/control.html index 7cf80db0d..165c0c917 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 "Order ID" %}
+
{% trans "Payment ID" %}
{{ payment_info.id }}
-
{% trans "Status" %}
-
{{ payment_info.status }}
+
{% trans "Sale ID" %}
+
{{ sale_id|default_if_none:"?" }}
{% trans "Payer" %}
-
{{ payment_info.payer.email_address }}
+
{{ payment_info.payer.payer_info.email }}
{% trans "Last update" %}
-
{{ payment_info.purchase_units.0.payments.captures.0.update_time }}
+
{{ payment_info.update_time }}
{% trans "Total value" %}
-
{{ payment_info.purchase_units.0.payments.captures.0.amount.value }}
+
{{ payment_info.transactions.0.amount.total }}
{% trans "Currency" %}
-
{{ payment_info.purchase_units.0.payments.captures.0.amount.currency_code }}
+
{{ payment_info.transactions.0.amount.currency }}
{% 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 deleted file mode 100644 index 165c0c917..000000000 --- a/src/pretix/plugins/paypal/templates/pretixplugins/paypal/control_legacy.html +++ /dev/null @@ -1,18 +0,0 @@ -{% 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 deleted file mode 100644 index 72d72293d..000000000 --- a/src/pretix/plugins/paypal/templates/pretixplugins/paypal/pay.html +++ /dev/null @@ -1,45 +0,0 @@ -{% 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 deleted file mode 100644 index 9f67e08d1..000000000 --- a/src/pretix/plugins/paypal/templates/pretixplugins/paypal/presale_head.html +++ /dev/null @@ -1,20 +0,0 @@ -{% 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 4b4d71dd3..361cec20e 100644 --- a/src/pretix/plugins/paypal/urls.py +++ b/src/pretix/plugins/paypal/urls.py @@ -24,8 +24,7 @@ from django.conf.urls import include, re_path from pretix.multidomain import event_url from .views import ( - PayView, XHRView, abort, isu_disconnect, isu_return, redirect_view, - success, webhook, + abort, oauth_disconnect, oauth_return, redirect_view, success, webhook, ) event_patterns = [ @@ -33,21 +32,17 @@ 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/', isu_disconnect, - name='isu.disconnect'), - re_path(r'^control/event/(?P[^/]+)/(?P[^/]+)/paypal/return/$', isu_return, name='isu.return'), + re_path(r'^control/event/(?P[^/]+)/(?P[^/]+)/paypal/disconnect/', + oauth_disconnect, name='oauth.disconnect'), re_path(r'^_paypal/webhook/$', webhook, name='webhook'), + re_path(r'^_paypal/oauth_return/$', oauth_return, name='oauth.return'), ] diff --git a/src/pretix/plugins/paypal/views.py b/src/pretix/plugins/paypal/views.py index ad76a7a88..8077bfc60 100644 --- a/src/pretix/plugins/paypal/views.py +++ b/src/pretix/plugins/paypal/views.py @@ -31,73 +31,36 @@ # 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 ( - Http404, HttpResponse, HttpResponseBadRequest, JsonResponse, -) +from django.http import HttpResponse, HttpResponseBadRequest from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse -from django.utils.decorators import method_decorator -from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ from django.views.decorators.clickjacking import xframe_options_exempt from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST -from django.views.generic import TemplateView from django_scopes import scopes_disabled -from paypalcheckoutsdk import orders as pp_orders, payments as pp_payments +from paypalrestsdk.openid_connect import Tokeninfo 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 PaypalMethod, PaypalMethod as Paypal -from pretix.presale.views import get_cart, get_cart_total +from pretix.plugins.paypal.payment import Paypal 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') @@ -113,136 +76,40 @@ 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() -@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): +def oauth_return(request, *args, **kwargs): + if 'payment_paypal_oauth_event' not in request.session: messages.error(request, _('An error occurred during connecting with PayPal, please try again.')) return redirect(reverse('control:index')) - event = get_object_or_404(Event, pk=request.session['payment_paypal_isu_event']) + event = get_object_or_404(Event, pk=request.session['payment_paypal_oauth_event']) - gs = GlobalSettingsObject() prov = Paypal(event) prov.init_api() try: - req = PartnersMerchantIntegrationsGetRequest( - gs.settings.get('payment_paypal_connect_partner_merchant_id'), - request.GET.get('merchantIdInPayPal') - ) - response = prov.client.execute(req) - except IOError as e: + tokeninfo = Tokeninfo.create(request.GET.get('code')) + userinfo = Tokeninfo.create_with_refresh_token(tokeninfo['refresh_token']).userinfo() + except paypalrestsdk.exceptions.ConnectionError: + logger.exception('Failed to obtain OAuth token') messages.error(request, _('An error occurred during connecting with PayPal, please try again.')) - logger.exception('PayPal PartnersMerchantIntegrationsGetRequest: {}'.format(str(e))) else: - params = ['merchant_id', 'tracking_id', 'payments_receivable', 'primary_email_confirmed'] - if not any(k in response.result for k in params): - if 'message' in response.result: - messages.error(request, response.result.message) - else: - messages.error(request, _('An error occurred during connecting with PayPal, please try again.')) - else: - if response.result.tracking_id != request.session['payment_paypal_isu_tracking_id']: - messages.error(request, _('An error occurred during connecting with PayPal, please try again.')) - else: - if request.GET.get("isEmailConfirmed") == "false": # Yes - literal! - messages.warning( - request, - _('The e-mail address on your PayPal account has not yet been confirmed. You will need to do ' - 'this before you can start accepting payments.') - ) - messages.success( - request, - _('Your PayPal account is now connected to pretix. You can change the settings in detail below.') - ) + messages.success(request, + _('Your PayPal account is now connected to pretix. You can change the settings in ' + 'detail below.')) - event.settings.payment_paypal_isu_merchant_id = response.result.merchant_id - - # Just for good measure: Let's keep a copy of the granted scopes - for integration in response.result.oauth_integrations: - if integration.integration_type == 'OAUTH_THIRD_PARTY': - for third_party in integration.oauth_third_party: - if third_party.partner_client_id == prov.client.environment.client_id: - event.settings.payment_paypal_isu_scopes = third_party.scopes + event.settings.payment_paypal_connect_refresh_token = tokeninfo['refresh_token'] + event.settings.payment_paypal_connect_user_id = userinfo.email return redirect(reverse('control:event.settings.payment.provider', kwargs={ 'organizer': event.organizer.slug, 'event': event.slug, - 'provider': 'paypal_settings' + 'provider': 'paypal' })) 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 @@ -257,7 +124,7 @@ def success(request, *args, **kwargs): else: payment = None - if request.session.get('payment_paypal_id', None): + if pid == request.session.get('payment_paypal_id', None): if payment: prov = Paypal(request.event) try: @@ -311,20 +178,18 @@ 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 ["checkout-order", "refund", "capture"]: + if event_json['resource_type'] not in ('sale', 'refund'): return HttpResponse("Not interested in this resource type", status=200) - # Retrieve the Charge ID of the refunded payment - if event_json['resource_type'] == 'refund': - payloadid = get_link(event_json['resource']['links'], 'up')['href'].split('/')[-1] + if event_json['resource_type'] == 'sale': + saleid = event_json['resource']['id'] else: - payloadid = event_json['resource']['id'] + saleid = event_json['resource']['sale_id'] try: - 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')) + refs = [saleid] + if event_json['resource'].get('parent_payment'): + refs.append(event_json['resource'].get('parent_payment')) rso = ReferencedPayPalObject.objects.select_related('order', 'order__event').get( reference__in=refs @@ -341,10 +206,8 @@ def webhook(request, *args, **kwargs): prov.init_api() try: - if rso: - payloadid = rso.payment.info_data['id'] - sale = prov.client.execute(pp_orders.OrdersGetRequest(payloadid)).result - except IOError: + sale = paypalrestsdk.Sale.find(saleid) + except paypalrestsdk.exceptions.ConnectionError: logger.exception('PayPal error on webhook. Event data: %s' % str(event_json)) return HttpResponse('Sale not found', status=500) @@ -355,58 +218,47 @@ def webhook(request, *args, **kwargs): info__icontains=sale['id']) payment = None for p in payments: - # Legacy PayPal info-data - if "purchase_units" not in p.info_data: - try: - req = pp_orders.OrdersGetRequest(p.info_data['cart']) - response = prov.client.execute(req) - p.info = json.dumps(response.result.dict()) - p.save(update_fields=['info']) - p.refresh_from_db() - except IOError: - logger.exception('PayPal error on webhook. Event data: %s' % str(event_json)) - return HttpResponse('Could not retrieve Order Data', status=500) - - for res in p.info_data['purchase_units'][0]['payments']['captures']: - if res['status'] in ['COMPLETED', 'PARTIALLY_REFUNDED'] and res['id'] == sale['id']: - payment = p - break + 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 if not payment: return HttpResponse('Payment not found', status=200) payment.order.log_action('pretix.plugins.paypal.event', data=event_json) - if payment.state == OrderPayment.PAYMENT_STATE_CONFIRMED and sale['status'] in ('PARTIALLY_REFUNDED', 'REFUNDED', 'COMPLETED'): + if payment.state == OrderPayment.PAYMENT_STATE_CONFIRMED and sale['state'] in ('partially_refunded', 'refunded'): if event_json['resource_type'] == 'refund': try: - req = pp_payments.RefundsGetRequest(event_json['resource']['id']) - refund = prov.client.execute(req).result - except IOError: + refund = paypalrestsdk.Refund.find(event_json['resource']['id']) + except paypalrestsdk.exceptions.ConnectionError: logger.exception('PayPal error on webhook. Event data: %s' % str(event_json)) return HttpResponse('Refund not found', status=500) known_refunds = {r.info_data.get('id'): r for r in payment.refunds.all()} if refund['id'] not in known_refunds: payment.create_external_refund( - amount=abs(Decimal(refund['amount']['value'])), - info=json.dumps(refund.dict() if not isinstance(refund, dict) else refund) + amount=abs(Decimal(refund['amount']['total'])), + info=json.dumps(refund.to_dict() if not isinstance(refund, dict) else refund) ) elif known_refunds.get(refund['id']).state in ( - OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT) and refund['status'] == 'COMPLETED': + OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT) and refund['state'] == 'completed': known_refunds.get(refund['id']).done() - if 'seller_payable_breakdown' in refund and 'total_refunded_amount' in refund['seller_payable_breakdown']: + if 'total_refunded_amount' in refund: known_sum = payment.refunds.filter( state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT, OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_SOURCE_EXTERNAL) ).aggregate(s=Sum('amount'))['s'] or Decimal('0.00') - total_refunded_amount = Decimal(refund['seller_payable_breakdown']['total_refunded_amount']['value']) + total_refunded_amount = Decimal(refund['total_refunded_amount']['value']) if known_sum < total_refunded_amount: payment.create_external_refund( amount=total_refunded_amount - known_sum ) - elif sale['status'] == 'REFUNDED': + elif sale['state'] == 'refunded': known_sum = payment.refunds.filter( state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT, OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_SOURCE_EXTERNAL) @@ -417,8 +269,7 @@ 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['status'] == 'COMPLETED': + OrderPayment.PAYMENT_STATE_CANCELED, OrderPayment.PAYMENT_STATE_FAILED) and sale['state'] == 'completed': try: payment.confirm() except Quota.QuotaExceededException: @@ -429,24 +280,14 @@ def webhook(request, *args, **kwargs): @event_permission_required('can_change_event_settings') @require_POST -def isu_disconnect(request, **kwargs): +def oauth_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_settings' + 'provider': 'paypal' })) - - -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 3f92626c8..4c4cc60ed 100644 --- a/src/setup.py +++ b/src/setup.py @@ -203,8 +203,7 @@ setup( 'oauthlib==3.1.*', 'openpyxl==3.0.*', 'packaging', - 'paypal-checkout-serversdk==1.0.*', - 'PyJWT==2.0.*', + 'paypalrestsdk==1.13.*', '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 0846ffa82..0dd923a46 100644 --- a/src/tests/plugins/paypal/test_checkout.py +++ b/src/tests/plugins/paypal/test_checkout.py @@ -67,55 +67,15 @@ 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 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) + 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) client, ticket = env session_key = get_cart_session_key(client, ticket.event) @@ -127,15 +87,7 @@ 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_wallet_oid': '04F89033701558004', - 'payment_paypal_wallet_payer': 'Q739JNKWH67HE', + 'payment': 'paypal' }) - print(response.content.decode()) - assert response['Location'] == '/ccc/30c3/checkout/confirm/' + assert response['Location'] == 'https://approve.url' diff --git a/src/tests/plugins/paypal/test_settings.py b/src/tests/plugins/paypal/test_settings.py index 831427235..d9a4729d1 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_settings' % (event.organizer.slug, event.slug), + response = client.get('/control/event/%s/%s/settings/payment/paypal' % (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 3b1571cd8..7395f619f 100644 --- a/src/tests/plugins/paypal/test_webhook.py +++ b/src/tests/plugins/paypal/test_webhook.py @@ -48,348 +48,190 @@ def env(): code='FOOBAR', event=event, email='dummy@dummy.test', status=Order.STATUS_PAID, datetime=now(), expires=now() + timedelta(days=10), - total=Decimal('43.59'), + total=Decimal('13.37'), ) o1.payments.create( amount=o1.total, provider='paypal', state=OrderPayment.PAYMENT_STATE_CONFIRMED, info=json.dumps({ - "id": "806440346Y391300T", - "status": "COMPLETED", - "purchase_units": [ + "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": [ { - "reference_id": "default", - "shipping": { - "name": { - "full_name": "test buyer" + "amount": { + "total": "7.47", + "currency": "USD", + "details": { + "subtotal": "7.47" } }, - "payments": { - "captures": [ - { - "id": "22A4162004478570J", - "status": "COMPLETED", + "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", "amount": { - "currency_code": "EUR", - "value": "43.59" + "total": "7.47", + "currency": "USD" }, - "final_capture": True, - "disbursement_mode": "INSTANT", - "seller_protection": { - "status": "ELIGIBLE", - "dispute_categories": [ - "ITEM_NOT_RECEIVED", - "UNAUTHORIZED_TRANSACTION" - ] + "protection_eligibility": "ELIGIBLE", + "protection_eligibility_type": "ITEM_NOT_RECEIVED_ELIGIBLE", + "transaction_fee": { + "value": "1.75", + "currency": "USD" }, - "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", + "parent_payment": "PAY-5YK922393D847794YKER7MUI", "links": [ { - "href": "https://api.sandbox.paypal.com/v2/payments/captures/22A4162004478570J", + "href": "https://api.paypal.com/v1/payments/sale/36C38912MN9658832", "rel": "self", "method": "GET" }, { - "href": "https://api.sandbox.paypal.com/v2/payments/captures/22A4162004478570J/refund", + "href": "https://api.paypal.com/v1/payments/sale/36C38912MN9658832/refund", "rel": "refund", "method": "POST" }, { - "href": "https://api.sandbox.paypal.com/v2/checkout/orders/806440346Y391300T", - "rel": "up", + "href": + "https://api.paypal.com/v1/payments/payment/PAY-5YK922393D847794YKER7MUI", + "rel": "parent_payment", "method": "GET" } - ], - "create_time": "2022-04-28T12:00:22Z", - "update_time": "2022-04-28T12:00:22Z" + ] } - ] - } + } + ] } ], - "payer": { - "name": { - "given_name": "test", - "surname": "buyer" - }, - "email_address": "dummy@dummy.dummy", - "payer_id": "Q739JNKWH67HE", - "address": { - "country_code": "DE" - } - }, "links": [ { - "href": "https://api.sandbox.paypal.com/v2/checkout/orders/806440346Y391300T", + "href": "https://api.paypal.com/v1/payments/payment/PAY-5YK922393D847794YKER7MUI", "rel": "self", "method": "GET" } ] + }) ) return event, o1 -def get_test_order(): - return {'id': '806440346Y391300T', - 'intent': 'CAPTURE', - 'status': 'COMPLETED', - 'purchase_units': [{'reference_id': 'default', - 'amount': {'currency_code': 'EUR', 'value': '43.59'}, - 'payee': {'email_address': 'dummy-facilitator@dummy.dummy', - 'merchant_id': 'G6R2B9YXADKWW'}, - 'description': 'Order JWJGC for PayPal v2', - 'custom_id': 'Order PAYPALV2-JWJGC', - 'soft_descriptor': 'MARTINFACIL', - 'payments': {'captures': [{'id': '22A4162004478570J', - 'status': 'COMPLETED', - 'amount': {'currency_code': 'EUR', 'value': '43.59'}, - 'final_capture': True, - 'disbursement_mode': 'INSTANT', - 'seller_protection': {'status': 'ELIGIBLE', - 'dispute_categories': [ - 'ITEM_NOT_RECEIVED', - 'UNAUTHORIZED_TRANSACTION']}, - 'seller_receivable_breakdown': { - 'gross_amount': {'currency_code': 'EUR', - 'value': '43.59'}, - 'paypal_fee': {'currency_code': 'EUR', 'value': '1.18'}, - 'net_amount': {'currency_code': 'EUR', - 'value': '42.41'}}, - 'custom_id': 'Order PAYPALV2-JWJGC', - 'links': [{ - 'href': 'https://api.sandbox.paypal.com/v2/payments/captures/22A4162004478570J', - 'rel': 'self', - 'method': 'GET'}, - { - 'href': 'https://api.sandbox.paypal.com/v2/payments/captures/22A4162004478570J/refund', - 'rel': 'refund', - 'method': 'POST'}, - { - 'href': 'https://api.sandbox.paypal.com/v2/checkout/orders/806440346Y391300T', - 'rel': 'up', - 'method': 'GET'}], - 'create_time': '2022-04-28T12:00:22Z', - 'update_time': '2022-04-28T12:00:22Z'}]}}], - 'payer': {'name': {'given_name': 'test', 'surname': 'buyer'}, - 'email_address': 'dummy@dummy.dummy', - 'payer_id': 'Q739JNKWH67HE', - 'address': {'country_code': 'DE'}}, - 'create_time': '2022-04-28T11:59:59Z', - 'update_time': '2022-04-28T12:00:22Z', - 'links': [{'href': 'https://api.sandbox.paypal.com/v2/checkout/orders/806440346Y391300T', - 'rel': 'self', - 'method': 'GET'}]} - - -def get_test_refund(): +def get_test_charge(order: Order): return { - "id": "1YK122615V244890X", + "id": "36C38912MN9658832", + "create_time": "2013-02-19T22:01:53Z", + "update_time": "2013-02-19T22:01:55Z", + "state": "completed", "amount": { - "currency_code": "EUR", - "value": "43.59" + "total": "7.47", + "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" - } + "protection_eligibility": "ELIGIBLE", + "protection_eligibility_type": "ITEM_NOT_RECEIVED_ELIGIBLE,UNAUTHORIZED_PAYMENT_ELIGIBLE", + "transaction_fee": { + "value": "1.75", + "currency": "USD" }, - "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", + "parent_payment": "PAY-5YK922393D847794YKER7MUI", "links": [ { - "href": "https://api.sandbox.paypal.com/v2/payments/refunds/1YK122615V244890X", + "href": "https://api.paypal.com/v1/payments/sale/36C38912MN9658832", "rel": "self", "method": "GET" }, { - "href": "https://api.sandbox.paypal.com/v2/payments/captures/22A4162004478570J", - "rel": "up", + "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", "method": "GET" } ] } -class Object(): - pass - - -def init_api(self): - class Client(): - environment = Object() - environment.client_id = '12345' - environment.merchant_id = 'G6R2B9YXADKWW' - - def execute(self, request): - response = Object() - response.result = request - return response - - self.client = Client() +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'} + ] + } @pytest.mark.django_db def test_webhook_all_good(env, client, monkeypatch): - order = env[1] - pp_order = get_test_order() - monkeypatch.setattr("paypalcheckoutsdk.orders.OrdersGetRequest", lambda *args: pp_order) - monkeypatch.setattr("pretix.plugins.paypal.payment.PaypalMethod.init_api", init_api) - - with scopes_disabled(): - ReferencedPayPalObject.objects.create(order=order, payment=order.payments.first(), - reference="806440346Y391300T") + charge = get_test_charge(env[1]) + monkeypatch.setattr("paypalrestsdk.Sale.find", lambda *args: charge) + monkeypatch.setattr("pretix.plugins.paypal.payment.Paypal.init_api", lambda *args: None) client.post('/dummy/dummy/paypal/webhook/', json.dumps( { - "id": "WH-4T867178D0574904F-7TT11736YU643990P", - "create_time": "2022-04-28T12:00:37.077Z", - "resource_type": "checkout-order", - "event_type": "CHECKOUT.ORDER.COMPLETED", - "summary": "Checkout Order Completed", + "id": "WH-2WR32451HC0233532-67976317FL4543714", + "create_time": "2014-10-23T17:23:52Z", + "resource_type": "sale", + "event_type": "PAYMENT.SALE.COMPLETED", + "summary": "A successful sale payment was made for $ 0.48 USD", "resource": { - "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" - } + "amount": { + "total": "-0.01", + "currency": "USD" }, - "status": "COMPLETED" + "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": "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" + "links": [], + "event_version": "1.0" } ), content_type='application_json') @@ -406,133 +248,33 @@ def test_webhook_global(env, client, monkeypatch): with scopes_disabled(): order.payments.update(state=OrderPayment.PAYMENT_STATE_PENDING) - pp_order = get_test_order() - monkeypatch.setattr("paypalcheckoutsdk.orders.OrdersGetRequest", lambda *args: pp_order) - monkeypatch.setattr("pretix.plugins.paypal.payment.PaypalMethod.init_api", init_api) - with scopes_disabled(): - ReferencedPayPalObject.objects.create(order=order, payment=order.payments.first(), - reference="806440346Y391300T") + 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") client.post('/_paypal/webhook/', json.dumps( { - "id": "WH-4T867178D0574904F-7TT11736YU643990P", - "create_time": "2022-04-28T12:00:37.077Z", - "resource_type": "checkout-order", - "event_type": "CHECKOUT.ORDER.COMPLETED", - "summary": "Checkout Order Completed", + "id": "WH-2WR32451HC0233532-67976317FL4543714", + "create_time": "2014-10-23T17:23:52Z", + "resource_type": "sale", + "event_type": "PAYMENT.SALE.COMPLETED", + "summary": "A successful sale payment was made for $ 0.48 USD", "resource": { - "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" - } + "amount": { + "total": "-0.01", + "currency": "USD" }, - "status": "COMPLETED" + "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": "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" + "links": [], + "event_version": "1.0" } ), content_type='application_json') @@ -548,93 +290,32 @@ def test_webhook_mark_paid(env, client, monkeypatch): with scopes_disabled(): order.payments.update(state=OrderPayment.PAYMENT_STATE_PENDING) - pp_order = get_test_order() - monkeypatch.setattr("paypalcheckoutsdk.orders.OrdersGetRequest", lambda *args: pp_order) - monkeypatch.setattr("pretix.plugins.paypal.payment.PaypalMethod.init_api", init_api) - with scopes_disabled(): - ReferencedPayPalObject.objects.create(order=order, payment=order.payments.first(), - reference="806440346Y391300T") + charge = get_test_charge(env[1]) + monkeypatch.setattr("paypalrestsdk.Sale.find", lambda *args: charge) + monkeypatch.setattr("pretix.plugins.paypal.payment.Paypal.init_api", lambda *args: None) client.post('/dummy/dummy/paypal/webhook/', json.dumps( { - "id": "WH-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", + "id": "WH-2WR32451HC0233532-67976317FL4543714", + "create_time": "2014-10-23T17:23:52Z", + "resource_type": "sale", + "event_type": "PAYMENT.SALE.COMPLETED", + "summary": "A successful sale payment was made for $ 0.48 USD", "resource": { - "disbursement_mode": "INSTANT", "amount": { - "value": "43.59", - "currency_code": "EUR" + "total": "-0.01", + "currency": "USD" }, - "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" + "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": "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" + "links": [], + "event_version": "1.0" } ), content_type='application_json') @@ -644,82 +325,43 @@ def test_webhook_mark_paid(env, client, monkeypatch): @pytest.mark.django_db def test_webhook_refund1(env, client, monkeypatch): - order = env[1] - pp_order = get_test_order() - pp_refund = get_test_refund() + charge = get_test_charge(env[1]) + charge['state'] = 'refunded' + refund = get_test_refund(env[1]) - 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") + 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) client.post('/dummy/dummy/paypal/webhook/', json.dumps( { - "id": "WH-5LJ60612747357339-66248625WA926672S", - "create_time": "2022-04-28T14:51:00.318Z", + # Sample obtained in a sandbox webhook + "id": "WH-9K829080KA1622327-31011919VC6498738", + "create_time": "2017-01-15T20:15:36Z", "resource_type": "refund", - "event_type": "PAYMENT.CAPTURE.REFUNDED", - "summary": "A EUR 43.59 EUR capture payment was refunded", + "event_type": "PAYMENT.SALE.REFUNDED", + "summary": "A EUR 255.41 EUR sale payment was refunded", "resource": { - "seller_payable_breakdown": { - "total_refunded_amount": { - "value": "43.59", - "currency_code": "EUR" - }, - "paypal_fee": { - "value": "1.18", - "currency_code": "EUR" - }, - "gross_amount": { - "value": "42.41", - "currency_code": "EUR" - }, - "net_amount": { - "value": "43.59", - "currency_code": "EUR" - } - }, "amount": { - "value": "43.59", - "currency_code": "EUR" + "total": "255.41", + "currency": "EUR" }, - "update_time": "2022-04-28T07:50:56-07:00", - "create_time": "2022-04-28T07:50:56-07:00", - "custom_id": "Order PAYPALV2-JWJGC", - "links": [ - { - "method": "GET", - "rel": "self", - "href": "https://api.sandbox.paypal.com/v2/payments/refunds/1YK122615V244890X" - }, - { - "method": "GET", - "rel": "up", - "href": "https://api.sandbox.paypal.com/v2/payments/captures/22A4162004478570J" - } - ], - "id": "1YK122615V244890X", - "status": "COMPLETED" + "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" }, - "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" + "links": [], + "event_version": "1.0" } ), content_type='application_json') @@ -738,82 +380,37 @@ def test_webhook_refund1(env, client, monkeypatch): @pytest.mark.django_db def test_webhook_refund2(env, client, monkeypatch): - order = env[1] - pp_order = get_test_order() - pp_refund = get_test_refund() + charge = get_test_charge(env[1]) + charge['state'] = 'refunded' + refund = get_test_refund(env[1]) - 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") + 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) client.post('/dummy/dummy/paypal/webhook/', json.dumps( { - "id": "WH-7FL378472F5218625-6WC87835CR8751809", - "create_time": "2022-04-28T14:56:08.160Z", + # Sample obtained in the webhook simulator + "id": "WH-2N242548W9943490U-1JU23391CS4765624", + "create_time": "2014-10-31T15:42:24Z", "resource_type": "refund", - "event_type": "PAYMENT.CAPTURE.REFUNDED", - "summary": "A EUR 43.59 EUR capture payment was refunded", + "event_type": "PAYMENT.SALE.REFUNDED", + "summary": "A 0.01 USD sale payment was refunded", "resource": { - "seller_payable_breakdown": { - "total_refunded_amount": { - "value": "43.59", - "currency_code": "EUR" - }, - "paypal_fee": { - "value": "01.18", - "currency_code": "EUR" - }, - "gross_amount": { - "value": "43.59", - "currency_code": "EUR" - }, - "net_amount": { - "value": "42.41", - "currency_code": "EUR" - } - }, "amount": { - "value": "43.59", - "currency_code": "EUR" + "total": "-0.01", + "currency": "USD" }, - "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" + "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": "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" + "links": [], + "event_version": "1.0" } ), content_type='application_json')