PayPal: Prevent race condition between refund and incoming webhook (Z#23185186) (#4911)

This commit is contained in:
Raphael Michel
2025-03-24 18:10:58 +01:00
committed by GitHub
parent de9a86c614
commit 8ec8b69755
2 changed files with 67 additions and 60 deletions

View File

@@ -916,53 +916,55 @@ class PaypalMethod(BasePaymentProvider):
def execute_refund(self, refund: OrderRefund): def execute_refund(self, refund: OrderRefund):
self.init_api() 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: for res in payment_info_data['purchase_units'][0]['payments']['captures']:
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']: if res['status'] in ['COMPLETED', 'PARTIALLY_REFUNDED']:
pp_payment = res.id pp_payment = res['id']
break break
req = CapturesRefundRequest(pp_payment) if not pp_payment:
req.request_body({ req = OrdersGetRequest(payment_info_data['id'])
"amount": { response = self.client.execute(req)
"value": self.format_price(refund.amount), for res in response.result.purchase_units[0].payments.captures:
"currency_code": refund.order.event.currency if res['status'] in ['COMPLETED', 'PARTIALLY_REFUNDED']:
} pp_payment = res.id
}) break
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()) req = CapturesRefundRequest(pp_payment)
refund.save(update_fields=['info']) 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) req = RefundsGetRequest(response.result.id)
response = self.client.execute(req) response = self.client.execute(req)

View File

@@ -38,6 +38,7 @@ from decimal import Decimal
from django.contrib import messages from django.contrib import messages
from django.core import signing from django.core import signing
from django.core.cache import cache from django.core.cache import cache
from django.db import transaction
from django.db.models import Sum from django.db.models import Sum
from django.http import ( from django.http import (
Http404, HttpResponse, HttpResponseBadRequest, JsonResponse, 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.services.cart import add_payment_to_cart, get_fees
from pretix.base.settings import GlobalSettingsObject from pretix.base.settings import GlobalSettingsObject
from pretix.control.permissions import event_permission_required from pretix.control.permissions import event_permission_required
from pretix.helpers import OF_SELF
from pretix.helpers.http import redirect_to_url from pretix.helpers.http import redirect_to_url
from pretix.multidomain.urlreverse import eventreverse from pretix.multidomain.urlreverse import eventreverse
from pretix.plugins.paypal2.client.customer.partners_merchantintegrations_get_request import ( 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)) logger.exception('PayPal error on webhook. Event data: %s' % str(event_json))
return HttpResponse('Refund not found', status=500) return HttpResponse('Refund not found', status=500)
known_refunds = {r.info_data.get('id'): r for r in payment.refunds.all()} with transaction.atomic():
if refund['id'] not in known_refunds: # Lock payment in case a refund is currently still running
payment.create_external_refund( payment = OrderPayment.objects.select_for_update(of=OF_SELF).get(pk=payment.pk)
amount=abs(Decimal(refund['amount']['value'])), known_refunds = {r.info_data.get('id'): r for r in payment.refunds.all()}
info=json.dumps(refund.dict() if not isinstance(refund, dict) else refund) if refund['id'] not in known_refunds:
)
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( 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': elif sale['status'] == 'REFUNDED':
known_sum = payment.refunds.filter( known_sum = payment.refunds.filter(
state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT, state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT,