BasePaymentProvider & PayPal2: allow to cancel pending payments on a per payment basis (Z#23240966) (#6472)

* move payment into pending on PENDING_REVIEW webhook

* mark approved payment as pending

* extend BasePaymentProvider to gate aborting pending payments on a payment per payment basis

* add timeout to paypal after which a pending payment can be canceled

* formatting

* add missing negation

* cleanup abort_pending_allowed methods

* Apply suggestions from code review

Co-authored-by: pajowu <pajowu@pajowu.de>

* check all capture elements

* rename method and change defaults

* remove left over Constant

* flake8 .

---------

Co-authored-by: pajowu <pajowu@pajowu.de>
This commit is contained in:
Lukas Bockstaller
2026-08-13 17:28:54 +02:00
committed by GitHub
co-authored by pajowu
parent 4e5fbacf6d
commit c4a5a9a84d
7 changed files with 239 additions and 24 deletions
+21 -4
View File
@@ -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'])
+53 -11
View File
@@ -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):
+2
View File
@@ -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):
@@ -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 %}</div>
{% else %}
{% elif stuck_in_compliance %}
<p>{% 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 %}</p>
{% endif %}
{% else %}
+7 -4
View File
@@ -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']
+4 -3
View File
@@ -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.'))
+147
View File
@@ -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