mirror of
https://github.com/pretix/pretix.git
synced 2026-05-05 15:14:04 +00:00
PayPal: Prevent race condition between refund and incoming webhook (Z#23185186) (#4911)
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user