From d15497f546433e5dfbccc3688a61285d9f8ce1b0 Mon Sep 17 00:00:00 2001 From: Vladimir Kalchenko Date: Fri, 8 May 2026 12:47:15 +0200 Subject: [PATCH] fix: ignore stale PAYMENT.AUTHORIZATION.VOIDED webhooks When a customer re-authorizes a PayPal payment, PayPal voids the old authorization and sends a PAYMENT.AUTHORIZATION.VOIDED webhook. Because both old and new authorizations share the same orderTransactionId in custom_id, the handler was cancelling the transaction even though the current authorization is still valid. Before cancelling, compare the webhook resource ID against the swag_paypal_resource_id stored on the order transaction. If they do not match, silently skip the stale webhook. --- CHANGELOG.md | 1 + src/Webhook/Handler/AuthorizationVoided.php | 10 ++++ .../Handler/AuthorizationVoidedV2Test.php | 50 +++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b95f0ecf..351e90022 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ # REPLACE_GLOBALLY_WITH_NEXT_VERSION +- Fixes an issue, where stale `PAYMENT.AUTHORIZATION.VOIDED` webhooks could cancel unrelated order transactions - Fixes an issue, where cookies are added even though associated payment methods are not active (shopware/SwagPayPal#457) - Fixes an issue, where the state of a country was not correctly transmitted to PayPal (shopware/SwagPayPal#469) diff --git a/src/Webhook/Handler/AuthorizationVoided.php b/src/Webhook/Handler/AuthorizationVoided.php index 0ff012ebd..466953340 100644 --- a/src/Webhook/Handler/AuthorizationVoided.php +++ b/src/Webhook/Handler/AuthorizationVoided.php @@ -13,6 +13,7 @@ use Shopware\PayPalSDK\Struct\V1\Webhook\Event; use Shopware\PayPalSDK\Struct\V1\Webhook\Resource; use Shopware\PayPalSDK\Struct\V2\Order\PurchaseUnit\Payments\Authorization; +use Swag\PayPal\SwagPayPal; use Swag\PayPal\Webhook\Exception\WebhookException; use Swag\PayPal\Webhook\WebhookEventTypes; @@ -37,6 +38,15 @@ public function invoke(Event $webhook, Context $context): void throw new WebhookException($this->getEventType(), 'Order transaction could not be resolved'); } + if ($resource instanceof Authorization) { + $customFields = $orderTransaction->getCustomFields() ?? []; + $storedResourceId = $customFields[SwagPayPal::ORDER_TRANSACTION_CUSTOM_FIELDS_PAYPAL_RESOURCE_ID] ?? null; + $isStaleAuthorization = $storedResourceId !== null && $storedResourceId !== $resource->getId(); + if ($isStaleAuthorization) { + return; + } + } + if ($this->isChangeAllowed($orderTransaction, OrderTransactionStates::STATE_CANCELLED)) { $this->orderTransactionStateHandler->cancel($orderTransaction->getId(), $context); } diff --git a/tests/Webhook/Handler/AuthorizationVoidedV2Test.php b/tests/Webhook/Handler/AuthorizationVoidedV2Test.php index 07d4c6d86..90f1e3a30 100644 --- a/tests/Webhook/Handler/AuthorizationVoidedV2Test.php +++ b/tests/Webhook/Handler/AuthorizationVoidedV2Test.php @@ -13,6 +13,8 @@ use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Uuid\Uuid; use Shopware\PayPalSDK\Struct\V1\Webhook\Event; +use Shopware\PayPalSDK\Struct\V2\Order\PurchaseUnit\Payments\Authorization; +use Swag\PayPal\SwagPayPal; use Swag\PayPal\Webhook\Exception\WebhookException; use Swag\PayPal\Webhook\Handler\AuthorizationVoided; use Swag\PayPal\Webhook\WebhookEventTypes; @@ -57,6 +59,54 @@ public function testInvokeWithoutTransaction(): void $this->assertInvokeWithoutTransaction(WebhookEventTypes::PAYMENT_AUTHORIZATION_VOIDED, $webhook, $reason); } + public function testInvokeWithStaleResourceId(): void + { + $context = Context::createDefaultContext(); + $container = $this->getContainer(); + $transactionId = $this->getTransactionId($context, $container); + + $this->orderTransactionRepository->update([[ + 'id' => $transactionId, + 'customFields' => [ + SwagPayPal::ORDER_TRANSACTION_CUSTOM_FIELDS_PAYPAL_RESOURCE_ID => 'stored-auth-id', + ], + ]], $context); + + $webhook = $this->createWebhookV2(Event::RESOURCE_TYPE_AUTHORIZATION); + $resource = $webhook->getResource(); + static::assertInstanceOf(Authorization::class, $resource); + $resource->setCustomId(\json_encode(['orderTransactionId' => $transactionId]) ?: null); + $resource->assign(['id' => 'different-auth-id']); + + $this->webhookHandler->invoke($webhook, $context); + + $this->assertOrderTransactionState(OrderTransactionStates::STATE_OPEN, $transactionId, $context); + } + + public function testInvokeWithMatchingResourceId(): void + { + $context = Context::createDefaultContext(); + $container = $this->getContainer(); + $transactionId = $this->getTransactionId($context, $container); + + $this->orderTransactionRepository->update([[ + 'id' => $transactionId, + 'customFields' => [ + SwagPayPal::ORDER_TRANSACTION_CUSTOM_FIELDS_PAYPAL_RESOURCE_ID => 'matching-auth-id', + ], + ]], $context); + + $webhook = $this->createWebhookV2(Event::RESOURCE_TYPE_AUTHORIZATION); + $resource = $webhook->getResource(); + static::assertInstanceOf(Authorization::class, $resource); + $resource->setCustomId(\json_encode(['orderTransactionId' => $transactionId]) ?: null); + $resource->assign(['id' => 'matching-auth-id']); + + $this->webhookHandler->invoke($webhook, $context); + + $this->assertOrderTransactionState(OrderTransactionStates::STATE_CANCELLED, $transactionId, $context); + } + public function testInvokeWithSameInitialState(): void { $webhook = $this->createWebhookV2(Event::RESOURCE_TYPE_AUTHORIZATION);