From b4bf300855ffcbdcaa9cfc93256d7ab052e9d2c2 Mon Sep 17 00:00:00 2001 From: Lukas Bockstaller Date: Mon, 17 Aug 2026 15:39:29 +0200 Subject: [PATCH] further fixes --- src/pretix/plugins/paypal2/views.py | 20 ++++++-- src/tests/plugins/paypal2/test_webhook.py | 59 +++++++++++++++++++++++ 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/src/pretix/plugins/paypal2/views.py b/src/pretix/plugins/paypal2/views.py index 31c8b394cc..22dcf61709 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) @@ -553,9 +560,12 @@ def isu_disconnect(request, **kwargs): })) -def get_link(links, rel): - for link in links: - if link['rel'] == rel: - return link +ORDER_ID_RE = re.compile(r"/checkout/orders/([^/?]+)") + +def get_order_id(links): + 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