diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f5c3d58c..21f085038 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +# 9.6.6 +- PPI-1044 - Improved compatibility of Vaulting with Store API usage and Headless setups + # 9.6.5 - PPI-1025 - Improves the performance of the installment banner in the Storefront - PPI-1043 - Fixes an issue, where a payment method is toggled twice in the Administration diff --git a/CHANGELOG_de-DE.md b/CHANGELOG_de-DE.md index 041218755..04054d621 100644 --- a/CHANGELOG_de-DE.md +++ b/CHANGELOG_de-DE.md @@ -1,3 +1,6 @@ +# 9.6.6 +- PPI-1044 - Verbesserte Kompatibilität von Vaulting mit der Store-API und Headless + # 9.6.5 - PPI-1025 - Verbessert die Performance des Ratenzahlungsbanners in der Storefront - PPI-1043 - Behebt ein Problem, bei dem eine Zahlungsmethode doppelt umgeschalten wurde diff --git a/src/Checkout/Exception/MissingCustomerVaultTokenException.php b/src/Checkout/Exception/MissingCustomerVaultTokenException.php new file mode 100644 index 000000000..e143d5b4d --- /dev/null +++ b/src/Checkout/Exception/MissingCustomerVaultTokenException.php @@ -0,0 +1,34 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\Checkout\Exception; + +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\ShopwareHttpException; +use Symfony\Component\HttpFoundation\Response; + +#[Package('checkout')] +class MissingCustomerVaultTokenException extends ShopwareHttpException +{ + public function __construct(string $customerId) + { + parent::__construct( + 'Missing vault token for customer "{{ customerId }}"', + ['customerId' => $customerId] + ); + } + + public function getStatusCode(): int + { + return Response::HTTP_BAD_REQUEST; + } + + public function getErrorCode(): string + { + return 'SWAG_PAYPAL__MISSING_CUSTOMER_VAULT_TOKEN'; + } +} diff --git a/src/Checkout/SalesChannel/CustomerVaultTokenRoute.php b/src/Checkout/SalesChannel/CustomerVaultTokenRoute.php new file mode 100644 index 000000000..429153843 --- /dev/null +++ b/src/Checkout/SalesChannel/CustomerVaultTokenRoute.php @@ -0,0 +1,74 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\Checkout\SalesChannel; + +use OpenApi\Attributes as OA; +use Shopware\Core\Checkout\Customer\CustomerException; +use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository; +use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria; +use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\System\SalesChannel\SalesChannelContext; +use Swag\PayPal\Checkout\Exception\MissingCustomerVaultTokenException; +use Swag\PayPal\Checkout\TokenResponse; +use Swag\PayPal\DataAbstractionLayer\VaultToken\VaultTokenEntity; +use Swag\PayPal\RestApi\V1\Resource\TokenResourceInterface; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; + +#[Package('checkout')] +#[Route(defaults: ['_routeScope' => ['store-api']])] +class CustomerVaultTokenRoute +{ + /** + * @internal + */ + public function __construct( + private EntityRepository $vaultRepository, + private TokenResourceInterface $tokenResource, + ) { + } + + #[OA\Get( + path: '/paypal/vault-token', + operationId: 'getPayPalCustomerVaultToken', + description: 'Tries to get the customer vault token', + tags: ['Store API', 'PayPal'], + responses: [new OA\Response( + response: Response::HTTP_OK, + description: 'The customer vault token', + content: new OA\JsonContent(properties: [new OA\Property( + property: 'token', + type: 'string' + )]) + )], + )] + #[Route(path: '/store-api/paypal/vault-token', name: 'store-api.paypal.vault.token', defaults: ['_loginRequired' => true, '_loginRequiredAllowGuest' => false], methods: ['GET'])] + public function getVaultToken(SalesChannelContext $context): TokenResponse + { + $customer = $context->getCustomer(); + if (!$customer || $customer->getGuest()) { + throw CustomerException::customerNotLoggedIn(); + } + + $criteria = new Criteria(); + $criteria->addFilter(new EqualsFilter('mainMapping.customerId', $customer->getId())); + $criteria->addFilter(new EqualsFilter('mainMapping.paymentMethodId', $context->getPaymentMethod()->getId())); + + /** @var VaultTokenEntity|null $vault */ + $vault = $this->vaultRepository->search($criteria, $context->getContext())->first(); + + $token = $this->tokenResource->getUserIdToken($context->getSalesChannelId(), $vault?->getTokenCustomer())->getIdToken(); + + if ($token === null) { + throw new MissingCustomerVaultTokenException($customer->getId()); + } + + return new TokenResponse($token); + } +} diff --git a/src/Resources/Schema/StoreApi/openapi.json b/src/Resources/Schema/StoreApi/openapi.json index 59b32eef3..8d10ef3f5 100644 --- a/src/Resources/Schema/StoreApi/openapi.json +++ b/src/Resources/Schema/StoreApi/openapi.json @@ -166,6 +166,33 @@ } } }, + "/paypal/vault-token": { + "get": { + "tags": [ + "Store API", + "PayPal" + ], + "description": "Tries to get the customer vault token", + "operationId": "getPayPalCustomerVaultToken", + "responses": { + "200": { + "description": "The customer vault token", + "content": { + "application/json": { + "schema": { + "properties": { + "token": { + "type": "string" + } + }, + "type": "object" + } + } + } + } + } + } + }, "/paypal/payment-method-eligibility": { "post": { "tags": [ diff --git a/src/Resources/config/services/checkout.xml b/src/Resources/config/services/checkout.xml index 396ba201c..cf4629a6d 100644 --- a/src/Resources/config/services/checkout.xml +++ b/src/Resources/config/services/checkout.xml @@ -45,6 +45,11 @@ + + + + + - + - + diff --git a/src/Storefront/Data/Service/SPBCheckoutDataService.php b/src/Storefront/Data/Service/SPBCheckoutDataService.php index bc9725f10..f6e47bba6 100644 --- a/src/Storefront/Data/Service/SPBCheckoutDataService.php +++ b/src/Storefront/Data/Service/SPBCheckoutDataService.php @@ -13,6 +13,7 @@ use Shopware\Core\System\SalesChannel\SalesChannelContext; use Shopware\Core\System\SystemConfig\SystemConfigService; use Swag\PayPal\Checkout\ExpressCheckout\SalesChannel\ExpressPrepareCheckoutRoute; +use Swag\PayPal\Checkout\SalesChannel\CustomerVaultTokenRoute; use Swag\PayPal\Checkout\SPBCheckout\SPBCheckoutButtonData; use Swag\PayPal\Setting\Service\CredentialsUtilInterface; use Swag\PayPal\Setting\Settings; @@ -37,7 +38,7 @@ public function __construct( RouterInterface $router, SystemConfigService $systemConfigService, CredentialsUtilInterface $credentialsUtil, - private readonly VaultDataService $vaultDataService, + private readonly CustomerVaultTokenRoute $customerVaultTokenRoute, ) { parent::__construct($paymentMethodDataRegistry, $localeCodeProvider, $router, $systemConfigService, $credentialsUtil); } @@ -75,7 +76,7 @@ public function buildCheckoutData( 'useAlternativePaymentMethods' => $this->systemConfigService->getBool(Settings::SPB_ALTERNATIVE_PAYMENT_METHODS_ENABLED, $salesChannelId), 'disabledAlternativePaymentMethods' => $this->getDisabledAlternativePaymentMethods($price, $currency->getIsoCode()), 'showPayLater' => $this->systemConfigService->getBool(Settings::SPB_SHOW_PAY_LATER, $salesChannelId), - 'userIdToken' => $this->vaultDataService->getUserIdToken($context), + 'userIdToken' => $this->customerVaultTokenRoute->getVaultToken($context)->getToken(), ])); } diff --git a/src/Storefront/Data/Service/VaultDataService.php b/src/Storefront/Data/Service/VaultDataService.php index e0af61dbe..ad5ab91f5 100644 --- a/src/Storefront/Data/Service/VaultDataService.php +++ b/src/Storefront/Data/Service/VaultDataService.php @@ -61,6 +61,9 @@ public function buildData(SalesChannelContext $context): ?VaultData return $struct; } + /** + * @deprecated tag:v10.0.0 - Will be removed. Use `CustomerVaultTokenRoute::getVaultToken` instead + */ public function getUserIdToken(SalesChannelContext $context): ?string { $customer = $context->getCustomer(); diff --git a/src/Storefront/Data/Service/VenmoCheckoutDataService.php b/src/Storefront/Data/Service/VenmoCheckoutDataService.php index e66d87061..91e6afd50 100644 --- a/src/Storefront/Data/Service/VenmoCheckoutDataService.php +++ b/src/Storefront/Data/Service/VenmoCheckoutDataService.php @@ -12,6 +12,7 @@ use Shopware\Core\Framework\Log\Package; use Shopware\Core\System\SalesChannel\SalesChannelContext; use Shopware\Core\System\SystemConfig\SystemConfigService; +use Swag\PayPal\Checkout\SalesChannel\CustomerVaultTokenRoute; use Swag\PayPal\Setting\Service\CredentialsUtilInterface; use Swag\PayPal\Storefront\Data\Struct\VenmoCheckoutData; use Swag\PayPal\Util\Lifecycle\Method\PaymentMethodDataRegistry; @@ -31,7 +32,7 @@ public function __construct( RouterInterface $router, SystemConfigService $systemConfigService, CredentialsUtilInterface $credentialsUtil, - private readonly VaultDataService $vaultDataService, + private readonly CustomerVaultTokenRoute $customerVaultTokenRoute, ) { parent::__construct($paymentMethodDataRegistry, $localeCodeProvider, $router, $systemConfigService, $credentialsUtil); } @@ -41,7 +42,7 @@ public function buildCheckoutData(SalesChannelContext $context, ?Cart $cart = nu $data = $this->getBaseData($context, $order); return (new VenmoCheckoutData())->assign(\array_merge($data, [ - 'userIdToken' => $this->vaultDataService->getUserIdToken($context), + 'userIdToken' => $this->customerVaultTokenRoute->getVaultToken($context)->getToken(), ])); } diff --git a/tests/Checkout/SalesChannel/CustomerVaultTokenRouteTest.php b/tests/Checkout/SalesChannel/CustomerVaultTokenRouteTest.php new file mode 100644 index 000000000..b81648427 --- /dev/null +++ b/tests/Checkout/SalesChannel/CustomerVaultTokenRouteTest.php @@ -0,0 +1,103 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\Test\Checkout\SalesChannel; + +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Shopware\Core\Checkout\Customer\CustomerException; +use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository; +use Shopware\Core\Framework\DataAbstractionLayer\Search\EntitySearchResult; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Test\Generator; +use Swag\PayPal\Checkout\Exception\MissingCustomerVaultTokenException; +use Swag\PayPal\Checkout\SalesChannel\CustomerVaultTokenRoute; +use Swag\PayPal\DataAbstractionLayer\VaultToken\VaultTokenEntity; +use Swag\PayPal\RestApi\V1\Api\Token; +use Swag\PayPal\RestApi\V1\Resource\TokenResourceInterface; + +/** + * @internal + */ +#[Package('checkout')] +class CustomerVaultTokenRouteTest extends TestCase +{ + private EntityRepository&MockObject $repository; + + private TokenResourceInterface&MockObject $tokenResource; + + private CustomerVaultTokenRoute $route; + + protected function setUp(): void + { + $this->repository = $this->createMock(EntityRepository::class); + $this->tokenResource = $this->createMock(TokenResourceInterface::class); + + $this->route = new CustomerVaultTokenRoute($this->repository, $this->tokenResource); + } + + public function testGetVaultTokenWithoutCustomer(): void + { + $salesChannelContext = Generator::generateSalesChannelContext(); + $salesChannelContext->assign(['customer' => null]); + + $this->expectException(CustomerException::class); + + $this->route->getVaultToken($salesChannelContext); + } + + public function testGetVaultTokenWithGuestCustomer(): void + { + $salesChannelContext = Generator::generateSalesChannelContext(); + $salesChannelContext->getCustomer()?->setGuest(true); + + $this->expectException(CustomerException::class); + + $this->route->getVaultToken($salesChannelContext); + } + + public function testGetVaultToken(): void + { + $salesChannelContext = Generator::generateSalesChannelContext(); + $salesChannelContext->getCustomer()?->setGuest(false); + + $entitySearchResult = $this->createMock(EntitySearchResult::class); + $this->repository->expects(static::once())->method('search')->willReturn($entitySearchResult); + $entitySearchResult->expects(static::once())->method('first')->willReturn(new VaultTokenEntity()); + + $token = new Token(); + $token->assign(['idToken' => 'dummy-token', 'expiresIn' => 45000]); + + $this->tokenResource->expects(static::once())->method('getUserIdToken')->willReturn($token); + + $response = $this->route->getVaultToken($salesChannelContext); + + static::assertSame( + $token->getIdToken(), + $response->getToken() + ); + } + + public function testVaultTokenIsNull(): void + { + $salesChannelContext = Generator::generateSalesChannelContext(); + $salesChannelContext->getCustomer()?->setGuest(false); + + $entitySearchResult = $this->createMock(EntitySearchResult::class); + $this->repository->expects(static::once())->method('search')->willReturn($entitySearchResult); + $entitySearchResult->expects(static::once())->method('first')->willReturn(new VaultTokenEntity()); + + $token = new Token(); + $token->assign(['idToken' => null, 'expiresIn' => 45000]); + + $this->tokenResource->expects(static::once())->method('getUserIdToken')->willReturn($token); + + $this->expectException(MissingCustomerVaultTokenException::class); + + $this->route->getVaultToken($salesChannelContext); + } +} diff --git a/tests/Storefront/Data/CheckoutSubscriberTest.php b/tests/Storefront/Data/CheckoutSubscriberTest.php index 6129eca0f..82bc4beda 100644 --- a/tests/Storefront/Data/CheckoutSubscriberTest.php +++ b/tests/Storefront/Data/CheckoutSubscriberTest.php @@ -8,6 +8,7 @@ namespace Swag\PayPal\Test\Storefront\Data; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\MockObject\Exception; use PHPUnit\Framework\TestCase; use Psr\Log\NullLogger; use Shopware\Core\Checkout\Payment\PaymentMethodCollection; @@ -28,6 +29,7 @@ use Swag\PayPal\Checkout\Payment\Method\SEPAHandler; use Swag\PayPal\Checkout\Payment\Method\VenmoHandler; use Swag\PayPal\Checkout\Payment\PayPalPaymentHandler; +use Swag\PayPal\Checkout\SalesChannel\CustomerVaultTokenRoute; use Swag\PayPal\RestApi\V2\PaymentIntentV2; use Swag\PayPal\Setting\Service\CredentialsUtil; use Swag\PayPal\Setting\Service\SettingsValidationService; @@ -37,7 +39,6 @@ use Swag\PayPal\Storefront\Data\Service\PayLaterCheckoutDataService; use Swag\PayPal\Storefront\Data\Service\SEPACheckoutDataService; use Swag\PayPal\Storefront\Data\Service\SPBCheckoutDataService; -use Swag\PayPal\Storefront\Data\Service\VaultDataService; use Swag\PayPal\Storefront\Data\Service\VenmoCheckoutDataService; use Swag\PayPal\Storefront\Data\Struct\AbstractCheckoutData; use Swag\PayPal\Storefront\Data\Struct\ACDCCheckoutData; @@ -285,6 +286,9 @@ public static function dataProviderPaymentMethods(): iterable ]; } + /** + * @throws Exception + */ private function createSubscriber(array $settingsOverride = []): CheckoutDataSubscriber { $settings = $this->createSystemConfigServiceMock(\array_merge([ @@ -321,7 +325,7 @@ private function createSubscriber(array $settingsOverride = []): CheckoutDataSub $router, $settings, $credentialsUtil, - $this->createMock(VaultDataService::class), + $this->createMock(CustomerVaultTokenRoute::class) ); $acdcDataService = new ACDCCheckoutDataService( @@ -338,7 +342,7 @@ private function createSubscriber(array $settingsOverride = []): CheckoutDataSub $router, $settings, $credentialsUtil, - $this->createMock(VaultDataService::class), + $this->createMock(CustomerVaultTokenRoute::class) ); $sessionMock = $this->createMock(Session::class); diff --git a/tests/Storefront/Data/Service/VaultDataServiceTest.php b/tests/Storefront/Data/Service/VaultDataServiceTest.php index d4cd15505..64389aaf0 100644 --- a/tests/Storefront/Data/Service/VaultDataServiceTest.php +++ b/tests/Storefront/Data/Service/VaultDataServiceTest.php @@ -16,7 +16,6 @@ use Shopware\Core\Test\Stub\DataAbstractionLayer\StaticEntityRepository; use Swag\PayPal\DataAbstractionLayer\VaultToken\VaultTokenCollection; use Swag\PayPal\DataAbstractionLayer\VaultToken\VaultTokenEntity; -use Swag\PayPal\RestApi\V1\Api\Token; use Swag\PayPal\RestApi\V1\Resource\TokenResourceInterface; use Swag\PayPal\Storefront\Data\Service\VaultDataService; use Swag\PayPal\Util\Lifecycle\Method\AbstractMethodData; @@ -159,83 +158,4 @@ public function testBuildDataWithExistingToken(): void static::assertSame('test-identifier', $data->getIdentifier()); static::assertSame('card', $data->getSnippetType()); } - - public function testGetUserIdTokenWithGuestCustomer(): void - { - $salesChannelContext = Generator::createSalesChannelContext(); - $salesChannelContext->getCustomer()?->setGuest(true); - - $service = new VaultDataService( - new StaticEntityRepository([]), - $this->createMock(PaymentMethodDataRegistry::class), - $this->createMock(TokenResourceInterface::class), - ); - - $token = $service->getUserIdToken($salesChannelContext); - static::assertNull($token); - } - - public function testGetUserIdTokenWithExistingToken(): void - { - $salesChannelContext = Generator::createSalesChannelContext(); - $salesChannelContext->getCustomer()?->setGuest(false); - - $token = new Token(); - $token->setIdToken('test-id-token'); - $tokenResource = $this->createMock(TokenResourceInterface::class); - $tokenResource - ->expects(static::once()) - ->method('getUserIdToken') - ->with($salesChannelContext->getSalesChannelId(), 'token-customer') - ->willReturn($token); - - $existingToken = new VaultTokenEntity(); - $existingToken->setId(Uuid::randomHex()); - $existingToken->setTokenCustomer('token-customer'); - - $repository = new StaticEntityRepository([static function (Criteria $criteria) use ($existingToken, $salesChannelContext): VaultTokenCollection { - static::assertInstanceOf(EqualsFilter::class, $criteria->getFilters()[0]); - static::assertSame('mainMapping.customerId', $criteria->getFilters()[0]->getField()); - static::assertSame($salesChannelContext->getCustomerId(), $criteria->getFilters()[0]->getValue()); - - static::assertInstanceOf(EqualsFilter::class, $criteria->getFilters()[1]); - static::assertSame('mainMapping.paymentMethodId', $criteria->getFilters()[1]->getField()); - static::assertSame($salesChannelContext->getPaymentMethod()->getId(), $criteria->getFilters()[1]->getValue()); - - return new VaultTokenCollection([$existingToken]); - }]); - - $service = new VaultDataService( - $repository, - $this->createMock(PaymentMethodDataRegistry::class), - $tokenResource, - ); - - $token = $service->getUserIdToken($salesChannelContext); - static::assertSame('test-id-token', $token); - } - - public function testGetUserIdTokenWithoutExistingToken(): void - { - $salesChannelContext = Generator::createSalesChannelContext(); - $salesChannelContext->getCustomer()?->setGuest(false); - - $token = new Token(); - $token->setIdToken('test-id-token'); - $tokenResource = $this->createMock(TokenResourceInterface::class); - $tokenResource - ->expects(static::once()) - ->method('getUserIdToken') - ->with($salesChannelContext->getSalesChannelId()) - ->willReturn($token); - - $service = new VaultDataService( - new StaticEntityRepository([new VaultTokenCollection()]), - $this->createMock(PaymentMethodDataRegistry::class), - $tokenResource, - ); - - $token = $service->getUserIdToken($salesChannelContext); - static::assertSame('test-id-token', $token); - } }