From 0b319d201cdb2eacdeb6d2d9212e3b809867f114 Mon Sep 17 00:00:00 2001 From: Lukas Bockstaller Date: Wed, 19 Aug 2026 08:41:25 +0200 Subject: [PATCH] handle captures that are declined (PRETIXEU-F8X & PRETIXEU-F93) (#6482) * handle captures that are declined * handle failed captures not stored in sale * further fixes * fix tests --- src/pretix/plugins/paypal2/views.py | 55 ++++++++++++++++++--- src/tests/plugins/paypal2/test_webhook.py | 59 +++++++++++++++++++++++ 2 files changed, 106 insertions(+), 8 deletions(-) diff --git a/src/pretix/plugins/paypal2/views.py b/src/pretix/plugins/paypal2/views.py index 0177a65856..4ca1b2fb1f 100644 --- a/src/pretix/plugins/paypal2/views.py +++ b/src/pretix/plugins/paypal2/views.py @@ -33,6 +33,7 @@ # License for the specific language governing permissions and limitations under the License. import json import logging +import re from decimal import Decimal from django.contrib import messages @@ -361,7 +362,13 @@ def webhook(request, *args, **kwargs): if event_json['resource_type'] == 'checkout-order': payloadid = event_json['resource']['id'] elif event_json['resource_type'] == 'refund' or event_json['resource_type'] == 'capture': - payloadid = get_link(event_json['resource']['links'], 'up')['href'].split('/')[-1] + payloadid = get_order_id(event_json.get('resource', {}).get('links', [])) + if payloadid is None: + # if we get a PAYMENT.CAPTURE.DECLINED webhook because a capture wasn't created due to + # violated validations, then it is labeled as a `capture` ressource_type but is in fact + # an `order` ressource_type as there is no `capture`. So we have to fall back + # See test_webhook_capture_declined for a redacted payload we've received + payloadid = event_json['resource']['id'] else: return HttpResponse("Not interested in this resource type", status=200) @@ -426,6 +433,22 @@ def webhook(request, *args, **kwargs): payment.info = json.dumps(sale.dict()) payment.save() + # the captures[] of the sales object only is populated if the capture request isn't rejected. + # the capture request might be rejected if certain validations aren't met OR if the payment is + # DECLINED, nevertheless we will get a webhook informing us about "PAYMENT.CAPTURE.DECLINED". + # With no trace of it in `sale`. + # So now we have to leave our current pattern of making only decisions based upon the + # complete payment object (and checking whenever we receive a webhook), and instead need to fail + # payment directly. + # Otherwise we are caught in a loop: + # 1. We get a webhook and get `sale` + # 2. We see no proof of a capture attempt in `sale` + # 3. We call execute_payment and trigger a new "PAYMENT.CAPTURE.DECLINED" webhook, GOTO 1 + if event_json['event_type'] == "PAYMENT.CAPTURE.DECLINED": + payment.fail(log_data={'status': event_json['event_type']}) + logger.exception('PayPal Webhook PAYMENT.CAPTURE.DECLINED: {}'.format(event_json)) + return HttpResponse(status=200) + if payment.state == OrderPayment.PAYMENT_STATE_CONFIRMED and sale['status'] in ('PARTIALLY_REFUNDED', 'REFUNDED', 'COMPLETED'): if event_json['resource_type'] == 'refund': try: @@ -473,6 +496,7 @@ def webhook(request, *args, **kwargs): if sale['status'] == 'COMPLETED': all_captures_completed = True any_pending_review = False + any_failed = None for purchaseunit in sale['purchase_units']: for capture in purchaseunit['payments']['captures']: try: @@ -481,14 +505,19 @@ def webhook(request, *args, **kwargs): except ReferencedPayPalObject.MultipleObjectsReturned: pass - if capture['status'] not in ('COMPLETED', 'REFUNDED', 'PARTIALLY_REFUNDED'): + if capture['status'] in ('COMPLETED', 'REFUNDED', 'PARTIALLY_REFUNDED'): + pass + elif capture['status'] in ("DECLINED", "FAILED"): all_captures_completed = False - if capture['status_details']['reason'] == "PENDING_REVIEW": + any_failed = True + elif capture['status'] in ('PENDING'): + all_captures_completed = False + if capture.get('status_details', {}).get('reason', "") == "PENDING_REVIEW": any_pending_review = True + else: + raise ValueError("Unknown paypal capture state: {}".format(capture['status'])) if all_captures_completed: try: - payment.info = json.dumps(sale.dict()) - payment.save(update_fields=['info']) payment.confirm() prov.log_payment_duration(payment) except Quota.QuotaExceededException: @@ -496,6 +525,8 @@ def webhook(request, *args, **kwargs): if any_pending_review and payment.state != OrderPayment.PAYMENT_STATE_PENDING: payment.state = OrderPayment.PAYMENT_STATE_PENDING payment.save(update_fields=['state']) + if any_failed: + payment.fail() elif sale['status'] == 'APPROVED': try: request.session['payment_paypal_oid'] = payment.info_data['id'] @@ -529,9 +560,17 @@ def isu_disconnect(request, **kwargs): })) -def get_link(links, rel): +ORDER_ID_RE = re.compile(r"/checkout/orders/([^/?]+)") + + +def get_order_id(links): for link in links: - if link['rel'] == rel: - return link + if link.get('rel', "") == "up" and link.get('href', None) is not None: + return link['href'].split('/')[-1] + + for link in links: + match = ORDER_ID_RE.search(link.get("href", "")) + if match: + return match.group(1) return None diff --git a/src/tests/plugins/paypal2/test_webhook.py b/src/tests/plugins/paypal2/test_webhook.py index 5fc2cceaf1..dde4624fd3 100644 --- a/src/tests/plugins/paypal2/test_webhook.py +++ b/src/tests/plugins/paypal2/test_webhook.py @@ -837,3 +837,62 @@ def test_webhook_pending_payment(env, client, monkeypatch): order.refresh_from_db() with scopes_disabled(): assert order.payments.first().state == OrderPayment.PAYMENT_STATE_PENDING + + +@pytest.mark.django_db +def test_webhook_capture_declined(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( + { + "create_time": "2026-08-17T12:23:30.687Z", + "event_type": "PAYMENT.CAPTURE.DECLINED", + "event_version": "1.0", + "id": "WH-XXXXXXXXXXXX-XXXXXXXXX", + "links": [ + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-XXXXXXXXXXXX-XXXXXXXXX", + "method": "GET", + "rel": "self" + }, + { + "href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-XXXXXXXXXXXX-XXXXXXXXX/resend", + "method": "POST", + "rel": "resend" + } + ], + "resource": { + "amount": {}, + "custom_id": "Order ABC-12345", + "disbursement_mode": "INSTANT", + "final_capture": True, + "id": "806440346Y391300T", + "payee": {}, + "seller_protection": {}, + "seller_receivable_breakdown": {}, + "status": "DECLINED", + "supplementary_data": {} + }, + "resource_type": "capture", + "resource_version": "2.0", + "summary": "A payment capture for € 30.0 EUR was declined." + }), content_type='application_json') + + order = env[1] + order.refresh_from_db() + with scopes_disabled(): + assert order.payments.first().state == OrderPayment.PAYMENT_STATE_FAILED