From 8ec8b6975594eef5c336e3b61a89df94169f87cb Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Mon, 24 Mar 2025 18:10:58 +0100 Subject: [PATCH] PayPal: Prevent race condition between refund and incoming webhook (Z#23185186) (#4911) --- src/pretix/plugins/paypal2/payment.py | 86 ++++++++++++++------------- src/pretix/plugins/paypal2/views.py | 41 +++++++------ 2 files changed, 67 insertions(+), 60 deletions(-) diff --git a/src/pretix/plugins/paypal2/payment.py b/src/pretix/plugins/paypal2/payment.py index 514f6ab6a6..f494d9c1b7 100644 --- a/src/pretix/plugins/paypal2/payment.py +++ b/src/pretix/plugins/paypal2/payment.py @@ -916,53 +916,55 @@ class PaypalMethod(BasePaymentProvider): def execute_refund(self, refund: OrderRefund): self.init_api() + with transaction.atomic(): + # Lock payment that we are creating refund for to prevent race condition with incoming webhook + OrderPayment.objects.select_for_update(of=OF_SELF).get(pk=refund.payment_id) + 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 - 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: + for res in payment_info_data['purchase_units'][0]['payments']['captures']: if res['status'] in ['COMPLETED', 'PARTIALLY_REFUNDED']: - pp_payment = res.id + pp_payment = res['id'] break - req = CapturesRefundRequest(pp_payment) - req.request_body({ - "amount": { - "value": self.format_price(refund.amount), - "currency_code": refund.order.event.currency - } - }) - response = self.client.execute(req) - except KeyError: - raise PaymentException(_('Refunding the amount via PayPal failed: The original payment does not contain ' - 'the required information to issue an automated refund.')) - except IOError as e: - refund.order.log_action('pretix.event.order.refund.failed', { - 'local_id': refund.local_id, - 'provider': refund.provider, - 'error': str(e) - }) - logger.error('execute_refund: {}'.format(str(e))) - raise PaymentException(_('Refunding the amount via PayPal failed: {}').format(str(e))) + if not pp_payment: + req = OrdersGetRequest(payment_info_data['id']) + response = self.client.execute(req) + for res in response.result.purchase_units[0].payments.captures: + if res['status'] in ['COMPLETED', 'PARTIALLY_REFUNDED']: + pp_payment = res.id + break - refund.info = json.dumps(response.result.dict()) - refund.save(update_fields=['info']) + req = CapturesRefundRequest(pp_payment) + req.request_body({ + "amount": { + "value": self.format_price(refund.amount), + "currency_code": refund.order.event.currency + } + }) + response = self.client.execute(req) + except KeyError: + raise PaymentException(_('Refunding the amount via PayPal failed: The original payment does not contain ' + 'the required information to issue an automated refund.')) + except IOError as e: + refund.order.log_action('pretix.event.order.refund.failed', { + 'local_id': refund.local_id, + 'provider': refund.provider, + 'error': str(e) + }) + logger.error('execute_refund: {}'.format(str(e))) + raise PaymentException(_('Refunding the amount via PayPal failed: {}').format(str(e))) + + refund.info = json.dumps(response.result.dict()) + refund.save(update_fields=['info']) req = RefundsGetRequest(response.result.id) response = self.client.execute(req) diff --git a/src/pretix/plugins/paypal2/views.py b/src/pretix/plugins/paypal2/views.py index 87e43d415b..9f617ce148 100644 --- a/src/pretix/plugins/paypal2/views.py +++ b/src/pretix/plugins/paypal2/views.py @@ -38,6 +38,7 @@ from decimal import Decimal from django.contrib import messages from django.core import signing from django.core.cache import cache +from django.db import transaction from django.db.models import Sum from django.http import ( Http404, HttpResponse, HttpResponseBadRequest, JsonResponse, @@ -62,6 +63,7 @@ from pretix.base.payment import PaymentException from pretix.base.services.cart import add_payment_to_cart, get_fees from pretix.base.settings import GlobalSettingsObject from pretix.control.permissions import event_permission_required +from pretix.helpers import OF_SELF from pretix.helpers.http import redirect_to_url from pretix.multidomain.urlreverse import eventreverse from pretix.plugins.paypal2.client.customer.partners_merchantintegrations_get_request import ( @@ -450,26 +452,29 @@ def webhook(request, *args, **kwargs): logger.exception('PayPal error on webhook. Event data: %s' % str(event_json)) return HttpResponse('Refund not found', status=500) - known_refunds = {r.info_data.get('id'): r for r in payment.refunds.all()} - if refund['id'] not in known_refunds: - payment.create_external_refund( - amount=abs(Decimal(refund['amount']['value'])), - info=json.dumps(refund.dict() if not isinstance(refund, dict) else refund) - ) - elif known_refunds.get(refund['id']).state in ( - OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT) and refund['status'] == 'COMPLETED': - known_refunds.get(refund['id']).done() - - if 'seller_payable_breakdown' in refund and 'total_refunded_amount' in refund['seller_payable_breakdown']: - known_sum = payment.refunds.filter( - state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT, - OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_SOURCE_EXTERNAL) - ).aggregate(s=Sum('amount'))['s'] or Decimal('0.00') - total_refunded_amount = Decimal(refund['seller_payable_breakdown']['total_refunded_amount']['value']) - if known_sum < total_refunded_amount: + with transaction.atomic(): + # Lock payment in case a refund is currently still running + payment = OrderPayment.objects.select_for_update(of=OF_SELF).get(pk=payment.pk) + 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=total_refunded_amount - known_sum + amount=abs(Decimal(refund['amount']['value'])), + info=json.dumps(refund.dict() if not isinstance(refund, dict) else refund) ) + elif known_refunds.get(refund['id']).state in ( + OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT) and refund['status'] == 'COMPLETED': + known_refunds.get(refund['id']).done() + + if 'seller_payable_breakdown' in refund and 'total_refunded_amount' in refund['seller_payable_breakdown']: + known_sum = payment.refunds.filter( + state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT, + OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_SOURCE_EXTERNAL) + ).aggregate(s=Sum('amount'))['s'] or Decimal('0.00') + total_refunded_amount = Decimal(refund['seller_payable_breakdown']['total_refunded_amount']['value']) + if known_sum < total_refunded_amount: + payment.create_external_refund( + amount=total_refunded_amount - known_sum + ) elif sale['status'] == 'REFUNDED': known_sum = payment.refunds.filter( state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT,