Revert "PayPal: Migrate to Order v2 API and ISU authentication (#2493)"

This reverts commit 9af1565db1.
This commit is contained in:
Raphael Michel
2022-04-28 20:58:39 +02:00
parent 6671d01c19
commit 3fc8e12d9a
21 changed files with 677 additions and 2230 deletions

View File

@@ -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