diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index c8047bbd5..4dc51c0b4 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -330,9 +330,24 @@ class BasePaymentProvider: payment method. This returns ``False`` by default which is no guarantee that aborting a pending payment can never happen, it just hides the frontend button to avoid users accidentally committing double payments. + If the decision doesn't depend on the specific payment, then only implementing + ``abort_pending_allowed`` is enough, ``payment_abort_pending_allowed(payment: OrderPayment)`` + is expected to take this into account. + As a consumer only evaluate ``payment_abort_pending_allowed(payment: OrderPayment)`` + to check if aborting this pending payment is possible. """ return False + def _payment_abort_pending_allowed(self, payment: OrderPayment) -> bool: + """ + Experimental: This might change during upcomming releases. + Whether or not a user can abort a payment in pending state to switch to another + payment method. This returns ``self.abort_pending_allowed`` by default which is + no guarantee that aborting a pending payment can never happen, it just hides the + frontend button to avoid users accidentally committing double payments. + """ + return self.abort_pending_allowed + @property def requires_invoice_immediately(self): """ @@ -1019,10 +1034,12 @@ class BasePaymentProvider: On success, you should set ``payment.state = OrderPayment.PAYMENT_STATE_CANCELED`` (or call the super method). On failure, you should raise a PaymentException. """ - if payment.state == OrderPayment.PAYMENT_STATE_PENDING and not self.abort_pending_allowed: - raise PaymentException(_( - "This payment is already being processed and can not be canceled any more." - )) + + if payment.state == OrderPayment.PAYMENT_STATE_PENDING: + if not self._payment_abort_pending_allowed(payment): + raise PaymentException(_( + "This payment is already being processed and cannot be canceled any more." + )) payment.state = OrderPayment.PAYMENT_STATE_CANCELED payment.save(update_fields=['state']) diff --git a/src/pretix/plugins/paypal2/payment.py b/src/pretix/plugins/paypal2/payment.py index 31634ce00..08cd341a0 100644 --- a/src/pretix/plugins/paypal2/payment.py +++ b/src/pretix/plugins/paypal2/payment.py @@ -23,7 +23,7 @@ import json import logging import urllib.parse from collections import OrderedDict -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from decimal import Decimal from django import forms @@ -193,6 +193,31 @@ class PaypalSettingsHolder(BasePaymentProvider): } ) )), + ('allow_retries_during_compliance_hold', + forms.BooleanField( + label=_('Allow further payments during compliance hold'), + help_text=_( + 'PayPals fraud prevention might block processing of individual payments for a considerable amount ' + 'of time. The payment is marked as "pending" during this time window. You can allow your customers to ' + 'start another payment attempts during that window. This might result in them being charged twice if the' + 'original payment is approved.' + ), + required=False + )), + ('timeout_payment_during_compliance_hold', + forms.IntegerField( + label=_('Timeout further payment attempts'), + help_text=_( + 'Time duration in minutes after which another payment attempt is possible, while the last payment is ' + 'still under investigation.' + ), + required=False, + widget=forms.NumberInput( + attrs={ + 'data-checkbox-dependency': '#id_payment_paypal_allow_retries_during_compliance_hold', + } + ) + )), ] @@ -515,8 +540,16 @@ class PaypalMethod(BasePaymentProvider): 'XPF': 0, })) - @property - def abort_pending_allowed(self): + def _payment_abort_pending_allowed(self, payment) -> bool: + if not self.settings.get('allow_retries_during_compliance_hold', as_type=bool, default=False): + return False + + if payment.info_data.get('create_time', False): + create_time = datetime.fromisoformat(payment.info_data['create_time']) + duration = self.settings.get('timeout_payment_during_compliance_hold', as_type=int, default=10) + if datetime.now(tz=timezone.utc) - create_time > timedelta(minutes=duration): + return True + return False def _create_paypal_order(self, request, payment=None, cart_total=None): @@ -678,6 +711,8 @@ class PaypalMethod(BasePaymentProvider): else: pp_captured_order = response.result payment.info = json.dumps(pp_captured_order.dict()) + if pp_captured_order.status == 'APPROVED': + payment.state = OrderPayment.PAYMENT_STATE_PENDING payment.save() try: @@ -857,14 +892,20 @@ class PaypalMethod(BasePaymentProvider): logger.info('{}: {} - paypal payment processing time'.format(str(payment.global_id), str(duration))) def payment_pending_render(self, request, payment) -> str: - retry = True + stuck_in_compliance = False + retry = self._payment_abort_pending_allowed(payment) try: - if ( - payment.info - and payment.info_data['purchase_units'][0]['payments']['captures'][0]['status'] == 'PENDING' - ): - retry = False - except (KeyError, IndexError): + for purchase_unit in payment.info_data['purchase_units']: + for capture in purchase_unit['payments']['captures']: + if capture['status'] == "PENDING": + stuck_in_compliance = True + except KeyError: + pass + + try: + if payment.info_data.get('status') == "APPROVED": + stuck_in_compliance = True + except (KeyError): pass error = payment.info_data.get("error", {}) @@ -872,7 +913,8 @@ class PaypalMethod(BasePaymentProvider): template = get_template('pretixplugins/paypal2/pending.html') ctx = {'request': request, 'event': self.event, 'settings': self.settings, - 'retry': retry, 'order': payment.order, 'is_known_issue': is_known_issue} + 'stuck_in_compliance': stuck_in_compliance, 'retry': retry, 'order': payment.order, + 'is_known_issue': is_known_issue} return template.render(ctx) def matching_id(self, payment: OrderPayment): diff --git a/src/pretix/plugins/paypal2/signals.py b/src/pretix/plugins/paypal2/signals.py index 00932625c..0735310c0 100644 --- a/src/pretix/plugins/paypal2/signals.py +++ b/src/pretix/plugins/paypal2/signals.py @@ -166,6 +166,8 @@ def signal_process_response(sender, request: HttpRequest, response: HttpResponse settings_hierarkey.add_default('payment_paypal_debug_buyer_country', '', str) settings_hierarkey.add_default('payment_paypal_method_wallet', True, bool) +settings_hierarkey.add_default('payment_paypal_allow_retries_during_compliance_hold', False, bool) +settings_hierarkey.add_default('payment_paypal_timeout_payment_during_compliance_hold', 10, int) def _nonce(request): diff --git a/src/pretix/plugins/paypal2/templates/pretixplugins/paypal2/pending.html b/src/pretix/plugins/paypal2/templates/pretixplugins/paypal2/pending.html index 640bbd4bc..83f1c210c 100644 --- a/src/pretix/plugins/paypal2/templates/pretixplugins/paypal2/pending.html +++ b/src/pretix/plugins/paypal2/templates/pretixplugins/paypal2/pending.html @@ -6,9 +6,12 @@ Your payment has failed due to a known issue within PayPal. Please try again, there is a high chance of the payment succeeding on a second or third attempt. You can also try other payment methods, if available. {% endblocktrans %} - {% else %} +{% elif stuck_in_compliance %}

{% blocktrans trimmed %} - Our attempt to execute your payment via PayPal has failed. Please try again or contact us. + Your payment is being processed by PayPal. This takes longer than usual. You can wait until PayPal + acknowledges the payment or you can try paying again with this or another payment method. + This might result in you being charged twice in case PayPal allows your initial payment attempt. + Please contact us to resolve this case. {% endblocktrans %}

{% endif %} {% else %} diff --git a/src/pretix/plugins/paypal2/views.py b/src/pretix/plugins/paypal2/views.py index 7a125e459..0177a6585 100644 --- a/src/pretix/plugins/paypal2/views.py +++ b/src/pretix/plugins/paypal2/views.py @@ -471,8 +471,8 @@ def webhook(request, *args, **kwargs): elif payment.state in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED, OrderPayment.PAYMENT_STATE_CANCELED, OrderPayment.PAYMENT_STATE_FAILED): if sale['status'] == 'COMPLETED': - any_captures = False all_captures_completed = True + any_pending_review = False for purchaseunit in sale['purchase_units']: for capture in purchaseunit['payments']['captures']: try: @@ -483,9 +483,9 @@ def webhook(request, *args, **kwargs): if capture['status'] not in ('COMPLETED', 'REFUNDED', 'PARTIALLY_REFUNDED'): all_captures_completed = False - else: - any_captures = True - if any_captures and all_captures_completed: + if capture['status_details']['reason'] == "PENDING_REVIEW": + any_pending_review = True + if all_captures_completed: try: payment.info = json.dumps(sale.dict()) payment.save(update_fields=['info']) @@ -493,6 +493,9 @@ def webhook(request, *args, **kwargs): prov.log_payment_duration(payment) except Quota.QuotaExceededException: pass + if any_pending_review and payment.state != OrderPayment.PAYMENT_STATE_PENDING: + payment.state = OrderPayment.PAYMENT_STATE_PENDING + payment.save(update_fields=['state']) elif sale['status'] == 'APPROVED': try: request.session['payment_paypal_oid'] = payment.info_data['id'] diff --git a/src/pretix/presale/views/order.py b/src/pretix/presale/views/order.py index c13164463..dc130502e 100644 --- a/src/pretix/presale/views/order.py +++ b/src/pretix/presale/views/order.py @@ -349,7 +349,7 @@ class OrderDetails(EventViewMixin, OrderDetailMixin, CartMixin, TicketPageMixin, pp = lp.payment_provider ctx['last_payment_info'] = pp.payment_pending_render(self.request, ctx['last_payment']) - if lp.state == OrderPayment.PAYMENT_STATE_PENDING and not pp.abort_pending_allowed: + if lp.state == OrderPayment.PAYMENT_STATE_PENDING and not pp._payment_abort_pending_allowed(lp): ctx['can_pay'] = False ctx['can_pay'] = ctx['can_pay'] and self.order._can_be_paid() is True @@ -611,7 +611,8 @@ class OrderPayChangeMethod(EventViewMixin, OrderDetailMixin, TemplateView): if self.open_payment: pp = self.open_payment.payment_provider - if self.open_payment.state == OrderPayment.PAYMENT_STATE_PENDING and not pp.abort_pending_allowed: + if self.open_payment.state == OrderPayment.PAYMENT_STATE_PENDING and not pp._payment_abort_pending_allowed( + self.open_payment): messages.error(request, _('A payment is currently pending for this order.')) return redirect(self.get_order_url()) @@ -1718,7 +1719,7 @@ class OrderChangeMixin: if totaldiff > Decimal('0.00') and self.order.status == Order.STATUS_PENDING: for p in self.order.payments.filter(state=OrderPayment.PAYMENT_STATE_PENDING): - if not p.payment_provider.abort_pending_allowed: + if not p.payment_provider._payment_abort_pending_allowed(p): raise OrderError(_('You may not change your order in a way that requires additional payment while ' 'we are processing your current payment. Please check back after your current ' 'payment has been accepted.')) diff --git a/src/tests/plugins/paypal2/test_webhook.py b/src/tests/plugins/paypal2/test_webhook.py index 21eb88853..5fc2cceaf 100644 --- a/src/tests/plugins/paypal2/test_webhook.py +++ b/src/tests/plugins/paypal2/test_webhook.py @@ -244,6 +244,61 @@ def get_test_refund(): } +def get_test_order_review_pending(): + 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': 'PENDING', + 'status_details': { + 'reason': 'PENDING_REVIEW' + }, + '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'}]} + + class Object(): pass @@ -690,3 +745,95 @@ def test_webhook_refund2(env, client, monkeypatch): assert r.payment == order.payments.first() assert r.state == OrderRefund.REFUND_STATE_EXTERNAL assert r.source == OrderRefund.REFUND_SOURCE_EXTERNAL + + +@pytest.mark.django_db +def test_webhook_pending_payment(env, client, monkeypatch): + order = env[1] + order.status = Order.STATUS_PENDING + order.save() + with scopes_disabled(): + order.payments.update(state=OrderPayment.PAYMENT_STATE_CREATED) + + pp_order = Result(get_test_order_review_pending()) + mock_orders_get_request = MagicMock(return_value=pp_order) + monkeypatch.setattr("paypalcheckoutsdk.orders.OrdersGetRequest", mock_orders_get_request) + monkeypatch.setattr("pretix.plugins.paypal2.payment.PaypalMethod.init_api", init_api) + with scopes_disabled(): + ReferencedPayPalObject.objects.create(order=order, payment=order.payments.first(), + reference="806440346Y391300T") + + assert order.payments.first().state == OrderPayment.PAYMENT_STATE_CREATED + + client.post('/_paypal/webhook/', json.dumps( + { + "id": "WH-0AH02875JL566842H-2AF03788XV8252724", + "create_time": "2022-04-28T12:00:37.077Z", + "resource_type": "capture", + "event_type": "PAYMENT.CAPTURE.PENDING", + "summary": "Payment pending for € 43.59 EUR", + "resource": { + "update_time": "2022-04-28T12:00:22Z", + "create_time": "2022-04-28T11:59:59Z", + "amount": { + "currency_code": "EUR", + "value": "43.59" + }, + "custom_id": "Order PAYPALV2-JWJGC", + "final_capture": True, + "id": "22A4162004478570J", + "links": [ + { + "href": "https://api.sandbox.paypal.com/v2/payments/captures/5M631111V9599860P", + "method": "GET", + "rel": "self" + }, + { + "href": "https://api.sandbox.paypal.com/v2/payments/captures/5M631111V9599860P/refund", + "method": "POST", + "rel": "refund" + }, + { + "href": "https://api.sandbox.paypal.com/v2/checkout/orders/806440346Y391300T", + "method": "GET", + "rel": "up" + } + ], + "payee": { + "email_address": "sb-ybfun52428692@business.example.com", + "merchant_id": "DLM8QKDR3CFZW" + }, + "seller_protection": { + "status": "NOT_ELIGIBLE" + }, + "status": "PENDING", + "status_details": { + "reason": "PENDING_REVIEW" + }, + "supplementary_data": { + "related_ids": { + "order_id": "9L827155WD164573M" + } + } + }, + "links": [ + { + "href": "https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-0AH02875JL566842H-2AF03788XV8252724", + "method": "GET", + "rel": "self" + }, + { + "href": "https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-0AH02875JL566842H-2AF03788XV8252724/resend", + "method": "POST", + "rel": "resend" + } + ], + "event_version": "1.0", + "resource_version": "2.0" + } + ), content_type='application_json') + + order = env[1] + order.refresh_from_db() + with scopes_disabled(): + assert order.payments.first().state == OrderPayment.PAYMENT_STATE_PENDING