From c2d50acf923ef044a3a50b08427c7e56e9a68fa2 Mon Sep 17 00:00:00 2001 From: Lennart Tinkloh <21331033+lernhart@users.noreply.github.com> Date: Mon, 4 Aug 2025 10:25:41 +0200 Subject: [PATCH 01/23] PayPal agent commerce (cherry-pick) --- .php-cs-fixer.dist.php | 1 - phpstan.neon.dist | 5 + .../Exception/AgentException.php | 172 +++++ .../Exception/HoneyWebhookException.php | 82 ++ src/AgentCommerce/HoneyWebhookController.php | 62 ++ src/AgentCommerce/HoneyWebhookResult.php | 37 + src/AgentCommerce/HoneyWebhookService.php | 173 +++++ .../Routing/AgentRequestContextResolver.php | 199 +++++ src/AgentCommerce/Routing/AgentRouteScope.php | 62 ++ src/AgentCommerce/Routing/AgentSource.php | 61 ++ .../AbstractAgentCommerceRoute.php | 51 ++ .../SalesChannel/CheckoutRoute.php | 90 +++ .../SalesChannel/CreateCartRoute.php | 105 +++ .../SalesChannel/GetCartRoute.php | 51 ++ .../Response/AgentCartResponse.php | 33 + .../SalesChannel/UpdateCartRoute.php | 157 ++++ .../Subscriber/ProductFilterSubscriber.php | 41 + .../Subscriber/WebhookSubscriber.php | 123 +++ src/AgentCommerce/Util/FaviconLoader.php | 49 ++ src/AgentCommerce/Util/PayPalCartFactory.php | 96 +++ .../Util/PayPalCartTransformer.php | 346 +++++++++ .../Util/ShopwareCartTransformer.php | 140 ++++ .../Validation/CartTokenValidator.php | 29 + .../Validation/ValidationIssues.php | 176 +++++ .../Cart/Service/ExcludedProductValidator.php | 2 +- .../SalesChannel/MethodEligibilityRoute.php | 2 +- src/DevOps/Command/GenerateOpenApi.php | 10 + .../Banner/Service/BannerDataService.php | 4 +- ...337399AddAgentCommerceSalesChannelType.php | 86 +++ src/Pos/Api/Common/PosStruct.php | 8 + .../Sync/ProductCleanupSyncHandler.php | 2 +- .../Service/ProductVisibilityCloneService.php | 2 +- src/Resources/Schema/AdminApi/openapi.json | 40 + .../src/constant/swag-paypal.constant.ts | 3 + .../api/swag-paypal-honey-webhook.service.ts | 19 + .../app/administration/src/global.types.ts | 2 + .../src/init/api-service.init.ts | 6 + src/Resources/app/administration/src/main.ts | 1 + .../sw-sales-channel-create/index.ts | 28 + .../sw-sales-channel-detail-base/index.ts | 73 ++ .../sw-sales-channel-detail-base.html.twig | 105 +++ .../sw-sales-channel-detail-base.scss | 36 + .../sw-sales-channel-detail-base.spec.ts | 105 +++ .../sw-sales-channel-detail/index.ts | 18 + .../sw-sales-channel-detail.html.twig | 6 + .../sw-sales-channel-detail.spec.ts | 79 ++ .../swag-paypal-agent-commerce/index.ts | 5 + .../snippet/de-DE.json | 12 + .../snippet/en-GB.json | 12 + .../static/product-export/body.txt | 27 + .../static/product-export/header.txt | 16 + .../swag-paypal-settings/snippet/de-DE.json | 3 + .../swag-paypal-settings/snippet/en-GB.json | 3 + .../administration/src/types/system-config.ts | 2 + src/Resources/config/routes.xml | 2 + .../config/services/agent_commerce.xml | 140 ++++ .../snippet/storefront/paypal.de-DE.json | 38 +- .../snippet/storefront/paypal.en-GB.json | 36 +- src/Setting/Settings.php | 1 + src/SwagPayPal.php | 7 +- src/Util/IntrospectionProcessor.php | 3 +- .../State/PaymentMethodStateService.php | 2 +- .../Registration/WebhookSubscriber.php | 2 +- tests/AgentCommerce/HoneyClientMock.php | 61 ++ .../AgentCommerce/HoneyWebhookServiceTest.php | 670 +++++++++++++++++ .../AgentContextResolverListenerTest.php | 55 ++ .../AgentRequestContextResolverTest.php | 562 ++++++++++++++ .../Routing/AgentRouteScopeTest.php | 119 +++ .../AgentCommerce/Routing/AgentSourceTest.php | 62 ++ .../SalesChannel/CheckoutRouteTest.php | 264 +++++++ .../SalesChannel/CreateCartRouteTest.php | 166 +++++ .../SalesChannel/GetCartRouteTest.php | 119 +++ .../Response/AgentCartResponseTest.php | 32 + .../AgentResponseExceptionSubscriberTest.php | 208 ++++++ .../SalesChannel/UpdateCartRouteTest.php | 397 ++++++++++ .../ProductFilterSubscriberTest.php | 91 +++ .../Subscriber/WebhookSubscriberTest.php | 141 ++++ .../Util/AgentDebugIDProcessorTest.php | 174 +++++ .../AgentCommerce/Util/FaviconLoaderTest.php | 124 ++++ .../Util/PayPalCartFactoryTest.php | 115 +++ .../Util/PayPalCartTransformerTest.php | 698 ++++++++++++++++++ .../Util/ShopwareCartTransformerTest.php | 169 +++++ .../Validation/CartTokenValidatorTest.php | 49 ++ .../Validation/ValidationIssuesTest.php | 291 ++++++++ .../PayPalExpressCheckoutDataServiceTest.php | 4 +- tests/Helper/ServicesTrait.php | 2 +- ...99AddAgentCommerceSalesChannelTypeTest.php | 60 ++ .../OrdersApi/Builder/APMOrderBuilderTest.php | 4 + tests/Pos/Run/PosSyncControllerTest.php | 4 + tests/Pos/Sync/CompleteProductTest.php | 8 +- tests/Pos/Sync/Inventory/UpdaterTrait.php | 2 +- tests/Pos/Webhook/InventoryChangedTest.php | 14 +- tests/Pos/Webhook/WebhookControllerTest.php | 8 +- .../Data/CheckoutSubscriberTest.php | 10 +- tests/Util/Lifecycle/UpdateTest.php | 8 +- 95 files changed, 7916 insertions(+), 64 deletions(-) create mode 100644 src/AgentCommerce/Exception/AgentException.php create mode 100644 src/AgentCommerce/Exception/HoneyWebhookException.php create mode 100644 src/AgentCommerce/HoneyWebhookController.php create mode 100644 src/AgentCommerce/HoneyWebhookResult.php create mode 100644 src/AgentCommerce/HoneyWebhookService.php create mode 100644 src/AgentCommerce/Routing/AgentRequestContextResolver.php create mode 100644 src/AgentCommerce/Routing/AgentRouteScope.php create mode 100644 src/AgentCommerce/Routing/AgentSource.php create mode 100644 src/AgentCommerce/SalesChannel/AbstractAgentCommerceRoute.php create mode 100644 src/AgentCommerce/SalesChannel/CheckoutRoute.php create mode 100644 src/AgentCommerce/SalesChannel/CreateCartRoute.php create mode 100644 src/AgentCommerce/SalesChannel/GetCartRoute.php create mode 100644 src/AgentCommerce/SalesChannel/Response/AgentCartResponse.php create mode 100644 src/AgentCommerce/SalesChannel/UpdateCartRoute.php create mode 100644 src/AgentCommerce/Subscriber/ProductFilterSubscriber.php create mode 100644 src/AgentCommerce/Subscriber/WebhookSubscriber.php create mode 100644 src/AgentCommerce/Util/FaviconLoader.php create mode 100644 src/AgentCommerce/Util/PayPalCartFactory.php create mode 100644 src/AgentCommerce/Util/PayPalCartTransformer.php create mode 100644 src/AgentCommerce/Util/ShopwareCartTransformer.php create mode 100644 src/AgentCommerce/Validation/CartTokenValidator.php create mode 100644 src/AgentCommerce/Validation/ValidationIssues.php create mode 100644 src/Migration/Migration1752337399AddAgentCommerceSalesChannelType.php create mode 100644 src/Resources/app/administration/src/core/service/api/swag-paypal-honey-webhook.service.ts create mode 100644 src/Resources/app/administration/src/module/swag-paypal-agent-commerce/extension/sw-sales-channel-create/index.ts create mode 100644 src/Resources/app/administration/src/module/swag-paypal-agent-commerce/extension/sw-sales-channel-detail-base/index.ts create mode 100644 src/Resources/app/administration/src/module/swag-paypal-agent-commerce/extension/sw-sales-channel-detail-base/sw-sales-channel-detail-base.html.twig create mode 100644 src/Resources/app/administration/src/module/swag-paypal-agent-commerce/extension/sw-sales-channel-detail-base/sw-sales-channel-detail-base.scss create mode 100644 src/Resources/app/administration/src/module/swag-paypal-agent-commerce/extension/sw-sales-channel-detail-base/sw-sales-channel-detail-base.spec.ts create mode 100644 src/Resources/app/administration/src/module/swag-paypal-agent-commerce/extension/sw-sales-channel-detail/index.ts create mode 100644 src/Resources/app/administration/src/module/swag-paypal-agent-commerce/extension/sw-sales-channel-detail/sw-sales-channel-detail.html.twig create mode 100644 src/Resources/app/administration/src/module/swag-paypal-agent-commerce/extension/sw-sales-channel-detail/sw-sales-channel-detail.spec.ts create mode 100644 src/Resources/app/administration/src/module/swag-paypal-agent-commerce/index.ts create mode 100644 src/Resources/app/administration/src/module/swag-paypal-agent-commerce/snippet/de-DE.json create mode 100644 src/Resources/app/administration/src/module/swag-paypal-agent-commerce/snippet/en-GB.json create mode 100644 src/Resources/app/administration/src/module/swag-paypal-agent-commerce/static/product-export/body.txt create mode 100644 src/Resources/app/administration/src/module/swag-paypal-agent-commerce/static/product-export/header.txt create mode 100644 src/Resources/config/services/agent_commerce.xml create mode 100644 tests/AgentCommerce/HoneyClientMock.php create mode 100644 tests/AgentCommerce/HoneyWebhookServiceTest.php create mode 100644 tests/AgentCommerce/Routing/AgentContextResolverListenerTest.php create mode 100644 tests/AgentCommerce/Routing/AgentRequestContextResolverTest.php create mode 100644 tests/AgentCommerce/Routing/AgentRouteScopeTest.php create mode 100644 tests/AgentCommerce/Routing/AgentSourceTest.php create mode 100644 tests/AgentCommerce/SalesChannel/CheckoutRouteTest.php create mode 100644 tests/AgentCommerce/SalesChannel/CreateCartRouteTest.php create mode 100644 tests/AgentCommerce/SalesChannel/GetCartRouteTest.php create mode 100644 tests/AgentCommerce/SalesChannel/Response/AgentCartResponseTest.php create mode 100644 tests/AgentCommerce/SalesChannel/Response/AgentResponseExceptionSubscriberTest.php create mode 100644 tests/AgentCommerce/SalesChannel/UpdateCartRouteTest.php create mode 100644 tests/AgentCommerce/Subscriber/ProductFilterSubscriberTest.php create mode 100644 tests/AgentCommerce/Subscriber/WebhookSubscriberTest.php create mode 100644 tests/AgentCommerce/Util/AgentDebugIDProcessorTest.php create mode 100644 tests/AgentCommerce/Util/FaviconLoaderTest.php create mode 100644 tests/AgentCommerce/Util/PayPalCartFactoryTest.php create mode 100644 tests/AgentCommerce/Util/PayPalCartTransformerTest.php create mode 100644 tests/AgentCommerce/Util/ShopwareCartTransformerTest.php create mode 100644 tests/AgentCommerce/Validation/CartTokenValidatorTest.php create mode 100644 tests/AgentCommerce/Validation/ValidationIssuesTest.php create mode 100644 tests/Migration/Migration1752337399AddAgentCommerceSalesChannelTypeTest.php diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 3b2a2db12..02032acff 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -24,7 +24,6 @@ 'general_phpdoc_annotation_remove' => ['annotations' => ['copyright', 'category']], 'linebreak_after_opening_tag' => false, 'method_argument_space' => ['on_multiline' => 'ensure_fully_multiline'], - 'method_chaining_indentation' => true, 'multiline_comment_opening_closing' => true, 'multiline_whitespace_before_semicolons' => true, 'native_function_invocation' => ['scope' => 'namespaced', 'strict' => false, 'exclude' => ['ini_get']], diff --git a/phpstan.neon.dist b/phpstan.neon.dist index e427ceab5..24d106454 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -102,6 +102,11 @@ services: - # register the class, so we can decorate it, but don't tag it as a rule, so only our decorator is used by PHPStan class: Symplify\PHPStanRules\Rules\NoReturnSetterMethodRule + - # Has to be fixed upstream, but we can ignore it for now + message: '#Call to deprecated method __construct\(\) of class Shopware\\Core\\Framework\\DataAbstractionLayer\\EntityDefinition#' + identifier: method.deprecated + path: 'tests/*' + rules: # Shopware core rules - Shopware\Core\DevOps\StaticAnalyze\PHPStan\Rules\Internal\InternalClassRule diff --git a/src/AgentCommerce/Exception/AgentException.php b/src/AgentCommerce/Exception/AgentException.php new file mode 100644 index 000000000..f4b635dac --- /dev/null +++ b/src/AgentCommerce/Exception/AgentException.php @@ -0,0 +1,172 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Exception; + +use Shopware\Core\Framework\Log\Package; +use Swag\PayPal\AgentCommerce\Struct\V1\AgentErrorDetail; +use Swag\PayPal\AgentCommerce\Struct\V1\AgentErrorDetailCollection; +use Symfony\Component\HttpFoundation\Response; + +#[Package('checkout')] +class AgentException extends AgentHttpException +{ + public const INVALID_REQUEST = 'INVALID_REQUEST'; + public const INVALID_CART_ID = 'INVALID_CART_ID'; + public const CART_NOT_FOUND = 'CART_NOT_FOUND'; + public const INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR'; + public const SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE'; + public const PAYMENT_PROCESSOR_UNAVAILABLE = 'PAYMENT_PROCESSOR_UNAVAILABLE'; + public const PAYMENT_CAPTURE_FAILED = 'PAYMENT_CAPTURE_FAILED'; + public const INVENTORY_SYSTEM_ERROR = 'INVENTORY_SYSTEM_ERROR'; + public const ORDER_SYSTEM_ERROR = 'ORDER_SYSTEM_ERROR'; + + public static function requiredFieldsMissing(string ...$fields): self + { + $message = 'Required field \'{{ fields }}\' is missing'; + $parameters = ['fields' => implode(', ', $fields)]; + $details = new AgentErrorDetailCollection(); + + foreach ($fields as $field) { + $detail = (new AgentErrorDetail()); + $detail->setField($field); + $detail->setIssue('MISSING_REQUIRED_FIELD'); + $detail->setDescription(\sprintf('The field \'%s\' is required and cannot be empty', $field)); + + $details->add($detail); + } + + return new self( + Response::HTTP_BAD_REQUEST, + self::INVALID_REQUEST, + $message, + $parameters, + $details + ); + } + + public static function requiredFieldInvalid(string $field, string $reason): self + { + $message = 'Required field \'{{ field }}\' is invalid: \'{{ reason }}\''; + $parameters = ['field' => $field, 'reason' => $reason]; + + $detail = new AgentErrorDetail(); + $detail->setField($field); + $detail->setIssue('MISSING_REQUIRED_FIELD'); + $detail->setDescription(\sprintf('The field \'%s\' is invalid: %s', $field, $reason)); + + return new self( + Response::HTTP_BAD_REQUEST, + self::INVALID_REQUEST, + $message, + $parameters, + new AgentErrorDetailCollection([$detail]) + ); + } + + public static function invalidJSONFormat(): self + { + return new self( + Response::HTTP_BAD_REQUEST, + self::INVALID_REQUEST, + 'Request body contains invalid JSON' + ); + } + + public static function unauthorized(string $message, ?\Throwable $previous = null): self + { + return new self( + Response::HTTP_UNAUTHORIZED, + self::INVALID_REQUEST, + $message, + previous: $previous, + ); + } + + public static function invalidCartId(): self + { + return new self( + Response::HTTP_BAD_REQUEST, + self::INVALID_CART_ID, + 'Cart ID format is invalid. Expected format: CART-[a-zA-Z0-9]{32}' + ); + } + + public static function cartNotFound(string $token): self + { + return new self( + Response::HTTP_NOT_FOUND, + self::CART_NOT_FOUND, + 'Cart with ID \'{{ token }}\' does not exist', + ['token' => $token] + ); + } + + public static function databaseConnectionFailure(): self + { + return new self( + Response::HTTP_INTERNAL_SERVER_ERROR, + self::INTERNAL_SERVER_ERROR, + 'A temporary system error occurred. Please try again later.' + ); + } + + public static function externalServiceFailure(): self + { + return new self( + Response::HTTP_INTERNAL_SERVER_ERROR, + self::SERVICE_UNAVAILABLE, + 'The payment processor is currently unavailable. Please try again later.' + ); + } + + public static function paymentProcessorUnavailable(): self + { + return new self( + Response::HTTP_INTERNAL_SERVER_ERROR, + self::PAYMENT_PROCESSOR_UNAVAILABLE, + 'Payment processing is temporarily unavailable' + ); + } + + public static function paymentCaptureFailed(string $message): self + { + $detail = (new AgentErrorDetail()); + $detail->setField('payment_method'); + $detail->setIssue('CAPTURE_FAILED'); + $detail->setDescription($message); + + $details = new AgentErrorDetailCollection([$detail]); + + return new self( + Response::HTTP_INTERNAL_SERVER_ERROR, + self::PAYMENT_CAPTURE_FAILED, + 'Unable to capture payment at this time', + [], + $details + ); + } + + public static function inventorySystemError(): self + { + return new self( + Response::HTTP_INTERNAL_SERVER_ERROR, + self::INVENTORY_SYSTEM_ERROR, + 'Unable to reserve inventory for checkout' + ); + } + + public static function orderSystemError(?\Throwable $previous = null): self + { + return new self( + Response::HTTP_INTERNAL_SERVER_ERROR, + self::ORDER_SYSTEM_ERROR, + 'Order could not be created due to system error', + previous: $previous, + ); + } +} diff --git a/src/AgentCommerce/Exception/HoneyWebhookException.php b/src/AgentCommerce/Exception/HoneyWebhookException.php new file mode 100644 index 000000000..d2e568d84 --- /dev/null +++ b/src/AgentCommerce/Exception/HoneyWebhookException.php @@ -0,0 +1,82 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Exception; + +use Shopware\Core\Framework\HttpException; +use Shopware\Core\Framework\Log\Package; +use Swag\PayPal\AgentCommerce\HoneyWebhookResult; +use Symfony\Component\HttpFoundation\Response; + +/** + * @internal + */ +#[Package('checkout')] +class HoneyWebhookException extends HttpException +{ + public const API_ERROR = 'API_ERROR'; + public const NOT_REGISTERED = 'NOT_REGISTERED'; + public const SALES_CHANNEL_NOT_FOUND = 'SALES_CHANNEL_NOT_FOUND'; + public const PRODUCT_EXPORT_NOT_FOUND = 'PRODUCT_EXPORT_NOT_FOUND'; + public const STOREFRONT_SALES_CHANNEL_NOT_FOUND = 'STOREFRONT_SALES_CHANNEL_NOT_FOUND'; + public const INVALID_PRODUCT_EXPORT_ROUTE = 'INVALID_PRODUCT_EXPORT_ROUTE'; + + public static function salesChannelNotRegistered(): self + { + return new self( + Response::HTTP_BAD_REQUEST, + self::NOT_REGISTERED, + 'Sales channel is not registered and can\'t be deregistered' + ); + } + + public static function invalidSalesChannel(): self + { + return new self( + Response::HTTP_BAD_REQUEST, + self::SALES_CHANNEL_NOT_FOUND, + 'Agent commerce sales channel not found' + ); + } + + public static function productExportNotFound(): self + { + return new self( + Response::HTTP_BAD_REQUEST, + self::PRODUCT_EXPORT_NOT_FOUND, + 'Product export sales channel not found' + ); + } + + public static function storefrontSalesChannelNotFound(): self + { + return new self( + Response::HTTP_BAD_REQUEST, + self::STOREFRONT_SALES_CHANNEL_NOT_FOUND, + 'Storefront sales channel not found' + ); + } + + public static function invalidProductExportRoute(): self + { + return new self( + Response::HTTP_BAD_REQUEST, + self::INVALID_PRODUCT_EXPORT_ROUTE, + 'Invalid product export route' + ); + } + + public static function invalidRequest(HoneyWebhookResult $result): self + { + return new self( + Response::HTTP_BAD_REQUEST, + self::API_ERROR . '_' . $result->error, + $result->message, + previous: $result->exception, + ); + } +} diff --git a/src/AgentCommerce/HoneyWebhookController.php b/src/AgentCommerce/HoneyWebhookController.php new file mode 100644 index 000000000..438df19a3 --- /dev/null +++ b/src/AgentCommerce/HoneyWebhookController.php @@ -0,0 +1,62 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Context; +use Shopware\Core\Framework\Log\Package; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; + +#[Package('checkout')] +#[Route(defaults: ['_routeScope' => ['api']])] +class HoneyWebhookController extends AbstractController +{ + /** + * @internal + */ + public function __construct( + private readonly HoneyWebhookService $webhookService, + ) { + } + + #[OA\Post( + path: '/_action/paypal/honey/webhook/register/{salesChannelId}', + operationId: 'registerHoneyWebhook', + tags: ['Admin Api', 'SwagPayPalWebhook'], + parameters: [new OA\Parameter( + parameter: 'salesChannelId', + name: 'salesChannelId', + in: 'path', + schema: new OA\Schema(type: 'string', pattern: '^[0-9a-f]{32}$') + )], + responses: [new OA\Response( + response: Response::HTTP_OK, + description: 'Returns the action taken for the webhook registration', + content: new OA\JsonContent(properties: [ + new OA\Property( + property: 'success', + type: 'boolean', + ), + new OA\Property( + property: 'message', + type: 'string', + ), + ]) + )] + )] + #[Route(path: '/api/_action/paypal/honey/webhook/register/{salesChannelId}', name: 'api.action.paypal.honey.webhook.register', methods: ['POST'], defaults: ['_acl' => ['swag_paypal.editor']])] + public function registerWebhook(string $salesChannelId, Context $context): JsonResponse + { + $result = $this->webhookService->register($salesChannelId, $context); + + return new JsonResponse($result->jsonSerialize(), Response::HTTP_OK); + } +} diff --git a/src/AgentCommerce/HoneyWebhookResult.php b/src/AgentCommerce/HoneyWebhookResult.php new file mode 100644 index 000000000..2512b8147 --- /dev/null +++ b/src/AgentCommerce/HoneyWebhookResult.php @@ -0,0 +1,37 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce; + +use GuzzleHttp\Exception\ClientException; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Struct; + +/** + * @internal + */ +#[Package('checkout')] +class HoneyWebhookResult extends Struct +{ + public function __construct( + public readonly bool $success, + public readonly string $message, + public readonly ?string $error, + public readonly ?ClientException $exception = null, + ) { + } + + public function jsonSerialize(): array + { + $data = parent::jsonSerialize(); + + unset($data['extensions']); + unset($data['exception']); + + return $data; + } +} diff --git a/src/AgentCommerce/HoneyWebhookService.php b/src/AgentCommerce/HoneyWebhookService.php new file mode 100644 index 000000000..3d69b5997 --- /dev/null +++ b/src/AgentCommerce/HoneyWebhookService.php @@ -0,0 +1,173 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce; + +use GuzzleHttp\ClientInterface; +use GuzzleHttp\Exception\ClientException; +use Lcobucci\JWT\Encoding\ChainedFormatter; +use Lcobucci\JWT\Encoding\JoseEncoder; +use Lcobucci\JWT\Signer\Hmac\Sha256; +use Lcobucci\JWT\Signer\Key\InMemory; +use Lcobucci\JWT\Token\Builder; +use Psr\Log\LoggerInterface; +use Shopware\Core\Framework\Context; +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\Country\CountryEntity; +use Shopware\Core\System\SalesChannel\SalesChannelCollection; +use Shopware\Core\System\SalesChannel\SalesChannelEntity; +use Shopware\Core\System\SystemConfig\SystemConfigService; +use Swag\PayPal\AgentCommerce\Exception\HoneyWebhookException; +use Swag\PayPal\AgentCommerce\Util\FaviconLoader; +use Swag\PayPal\Setting\Service\CredentialsUtil; +use Swag\PayPal\Setting\Settings; +use Swag\PayPal\SwagPayPal; +use Symfony\Component\Routing\RouterInterface; + +/** + * @internal + */ +#[Package('checkout')] +class HoneyWebhookService +{ + /** + * @param EntityRepository $salesChannelRepository + */ + public function __construct( + private readonly ClientInterface $client, + private readonly EntityRepository $salesChannelRepository, + private readonly CredentialsUtil $credentialsUtil, + private readonly RouterInterface $router, + private readonly SystemConfigService $systemConfigService, + private readonly LoggerInterface $logger, + private readonly FaviconLoader $faviconLoader, + ) { + } + + public function register(string $salesChannelId, Context $context): HoneyWebhookResult + { + try { + $this->deregister($salesChannelId); + } catch (HoneyWebhookException) { + // Is logged already + } + + $token = $this->createToken($salesChannelId, $context); + $result = $this->webhookCall($token, 'install'); + if ($result->success) { + $this->systemConfigService->set(Settings::AGENT_COMMERCE_ONBOARDED, $token, $salesChannelId); + } else { + $this->systemConfigService->delete(Settings::AGENT_COMMERCE_ONBOARDED, $salesChannelId); + + throw HoneyWebhookException::invalidRequest($result); + } + + return $result; + } + + public function deregister(string $salesChannelId): HoneyWebhookResult + { + try { + $oldToken = $this->systemConfigService->get(Settings::AGENT_COMMERCE_ONBOARDED, $salesChannelId); + if (!\is_string($oldToken)) { + throw HoneyWebhookException::salesChannelNotRegistered(); + } + + $result = $this->webhookCall($oldToken, 'uninstall'); + $this->systemConfigService->delete(Settings::AGENT_COMMERCE_ONBOARDED, $salesChannelId); + if (!$result->success) { + throw HoneyWebhookException::invalidRequest($result); + } + + return $result; + } catch (HoneyWebhookException $e) { + $this->logger->error('PayPal agent commerce webhook livecycle: {message}', ['message' => $e->getMessage(), 'exception' => $e]); + + throw $e; + } + } + + private function createToken(string $salesChannelId, Context $context): string + { + try { + $salesChannel = $this->loadSalesChannel($salesChannelId, $context); + if (!$salesChannel || !$salesChannel->getActive()) { + throw HoneyWebhookException::invalidSalesChannel(); + } + + $productExport = $salesChannel->getProductExports()?->first(); + if (!$productExport) { + throw HoneyWebhookException::productExportNotFound(); + } + + $storefront = $productExport->getStorefrontSalesChannel(); + if (!$storefront) { + throw HoneyWebhookException::storefrontSalesChannelNotFound(); + } + + $route = $this->router->getRouteCollection()->get('store-api.product.export'); + if (!$route) { + throw HoneyWebhookException::invalidProductExportRoute(); + } + + $path = str_replace(['{accessKey}', '{fileName}'], [$productExport->getAccessKey(), $productExport->getFileName()], $route->getPath()); + $url = $storefront->getHreflangDefaultDomain()?->getUrl() ?? $storefront->getDomains()?->first()?->getUrl(); + + return Builder::new(new JoseEncoder(), ChainedFormatter::default()) + ->withClaim('storeName', $salesChannel->getTranslation('name')) + ->withClaim('storeUrl', $url) + ->withClaim('country', $salesChannel->getCountry()?->getIso()) + ->withClaim('currency', $salesChannel->getCurrency()?->getIsoCode()) + ->withClaim('favIcon', $this->faviconLoader->loadFaviconLink($storefront->getId(), $context)) + ->withClaim('shippingCountries', array_values($storefront->getCountries()?->map(fn (CountryEntity $country) => $country->getIso()) ?? [])) + ->withClaim('paypalMerchantId', $this->credentialsUtil->getMerchantPayerId($storefront->getId())) + ->withClaim('shopwareMerchantId', $salesChannel->getId()) + ->withClaim('catalogDownloadUrl', rtrim($url ?? '', '/') . $path) + ->getToken(new Sha256(), InMemory::plainText(random_bytes(32))) + ->toString(); + } catch (HoneyWebhookException $e) { + $this->logger->error('PayPal agent commerce cannot create token: {message}', ['message' => $e->getMessage(), 'exception' => $e]); + + throw $e; + } + } + + private function loadSalesChannel(string $salesChannelId, Context $context): ?SalesChannelEntity + { + $criteria = new Criteria([$salesChannelId]); + $criteria->addFilter(new EqualsFilter('typeId', SwagPayPal::SALES_CHANNEL_TYPE_AGENT_COMMERCE)); + $criteria->addAssociations([ + 'country', + 'currency', + 'domains', + 'productExports.storefrontSalesChannel.domains', + 'productExports.storefrontSalesChannel.hreflangDefaultDomain', + 'productExports.storefrontSalesChannel.countries', + ]); + + return $this->salesChannelRepository->search($criteria, $context)->first(); + } + + private function webhookCall(string $token, string $endpoint): HoneyWebhookResult + { + try { + $response = $this->client->request('POST', 'webhooks/sw/' . $endpoint, ['body' => $token]); + } catch (ClientException $e) { + $response = $e->getResponse(); + } + + $content = json_decode($response->getBody()->getContents(), true); + $result = new HoneyWebhookResult($content['success'] ?? !isset($content['error']), $content['message'], $content['error'] ?? null, $e ?? null); + + $this->logger->log($result->success ? 'info' : 'error', 'PayPal agent commerce webhook ' . $endpoint, $result->jsonSerialize()); + + return $result; + } +} diff --git a/src/AgentCommerce/Routing/AgentRequestContextResolver.php b/src/AgentCommerce/Routing/AgentRequestContextResolver.php new file mode 100644 index 000000000..59e0d7c9a --- /dev/null +++ b/src/AgentCommerce/Routing/AgentRequestContextResolver.php @@ -0,0 +1,199 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Routing; + +use Lcobucci\JWT\Signer\Key\InMemory; +use Lcobucci\JWT\Signer\Rsa\Sha256; +use Lcobucci\JWT\Validation\Constraint\IssuedBy; +use Lcobucci\JWT\Validation\Constraint\LooseValidAt; +use Lcobucci\JWT\Validation\Constraint\SignedWith; +use Lcobucci\JWT\Validation\RequiredConstraintsViolated; +use Shopware\Core\Content\ProductExport\ProductExportCollection; +use Shopware\Core\Framework\Context; +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\JWT\JWTDecoder; +use Shopware\Core\Framework\JWT\JWTException; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Routing\RequestContextResolverInterface; +use Shopware\Core\Framework\Routing\RouteScopeCheckTrait; +use Shopware\Core\Framework\Routing\RouteScopeRegistry; +use Shopware\Core\Framework\Util\Random; +use Shopware\Core\Framework\Validation\Constraint\Uuid; +use Shopware\Core\Framework\Validation\DataValidationDefinition; +use Shopware\Core\Framework\Validation\DataValidator; +use Shopware\Core\Framework\Validation\Exception\ConstraintViolationException; +use Shopware\Core\PlatformRequest; +use Shopware\Core\System\SalesChannel\Context\SalesChannelContextService; +use Shopware\Core\System\SalesChannel\Context\SalesChannelContextServiceParameters; +use Swag\PayPal\AgentCommerce\Exception\AgentException; +use Swag\PayPal\AgentCommerce\Validation\CartTokenValidator; +use Swag\PayPal\AgentCommerce\Validation\HasScopes; +use Swag\PayPal\SwagPayPal; +use Symfony\Component\Clock\NativeClock; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Validator\Constraints\All; +use Symfony\Component\Validator\Constraints\NotBlank; +use Symfony\Component\Validator\Constraints\Optional; +use Symfony\Component\Validator\Constraints\Regex; +use Symfony\Component\Validator\Constraints\Type; + +/** + * @internal + */ +#[Package('checkout')] +class AgentRequestContextResolver implements RequestContextResolverInterface +{ + use RouteScopeCheckTrait; + + public const JWT_EXPECTED_ISSUER = 'paypal.com'; + + /** + * This is a hardcoded public key for PayPal JWT validation + * We use this as long as PayPal does not provide a way to retrieve the public key dynamically + * + * @var non-empty-string + */ + public static string $PAYPAL_JWT = '-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvv7Pi1nWWrJj4n5+6gX9 +B7BQpctaPEg9VdVK1kzc9xBNwZobeWEgEmiUGtkrn8S5R6Q4NmB4hnb8F5jeCX5O +kyA49mgzw4wNXUPGTGMY5Eoxt9zu1Heaivkljh4+wN6d01oIFkHT6E7VjEJOG2RA +49t7fgQ1phJIUK39B0RAXIG2pYicbujeiiJ12iQipMjY/TVD0KZgUc2Vj2apk7Dv +1YBqFG+HlSG5hWu880IzGQE9Pds5qekIawJJyed08otq29hDHlFd28B0fFhdzcu8 +cN83NxddXBlh77b8+a7gaWC5/Iw45THRpIsiG41uX0r0INEDcnR3qCUkz6m9LOVW +kQIDAQAB +-----END PUBLIC KEY-----'; + + /** + * @internal + * + * @param EntityRepository $productExportRepository + */ + public function __construct( + private readonly DataValidator $validator, + private readonly EntityRepository $productExportRepository, + private readonly JWTDecoder $JWTDecoder, + private readonly RouteScopeRegistry $routeScopeRegistry, + private readonly SalesChannelContextService $contextService, + ) { + } + + public function resolve(Request $request): void + { + if ($request->attributes->has(PlatformRequest::ATTRIBUTE_CONTEXT_OBJECT)) { + return; + } + + if (!$this->isRequestScoped($request, AgentRouteScope::class)) { + return; + } + + $token = $request->headers->get('Authorization'); + + if (!$token) { + throw AgentException::unauthorized('Missing Authorization header'); + } + + $source = $this->resolveContextSource($token); + $context = new Context($source); + + $criteria = new Criteria(); + $criteria->addFilter( + new EqualsFilter('storefrontSalesChannel.active', true), + new EqualsFilter('salesChannel.active', true), + new EqualsFilter('salesChannel.typeId', SwagPayPal::SALES_CHANNEL_TYPE_AGENT_COMMERCE), + new EqualsFilter('salesChannel.id', $source->salesChannelId), + ); + + $productExport = $this->productExportRepository->search($criteria, $context)->first(); + if (!$productExport) { + throw AgentException::unauthorized('Sales channel not found'); + } + + $source->setStreamId($productExport->getProductStreamId()); + + preg_match(\sprintf('/%s/', CartTokenValidator::REGEX), $request->getPathInfo(), $matches); + + $salesChannelContext = $this->contextService->get(new SalesChannelContextServiceParameters( + salesChannelId: $productExport->getStorefrontSalesChannelId(), + token: $matches[1] ?? Random::getAlphanumericString(32), + originalContext: $context, + )); + + $request->attributes->set(PlatformRequest::ATTRIBUTE_CONTEXT_OBJECT, $context); + $request->attributes->set(PlatformRequest::ATTRIBUTE_SALES_CHANNEL_CONTEXT_OBJECT, $salesChannelContext); + + try { + $this->validateJWT($request, $token); + } catch (RequiredConstraintsViolated $e) { + /** @deprecated tag:v11.0.0 - Remove RequiredConstraintViolated from caught Exceptions, it is a fix for 6.7.0.0 specifically */ + // this is a workaround for the JWTDecoder which does not catch RequiredConstraintsViolated exceptions in 6.7.0.0 + throw AgentException::unauthorized('Invalid JWT token', $e); + } catch (JWTException $e) { + throw AgentException::unauthorized('Invalid JWT token', $e->getPrevious()); + } + } + + protected function getScopeRegistry(): RouteScopeRegistry + { + return $this->routeScopeRegistry; + } + + private function validateJWT(Request $request, string $jwt): void + { + /** @var list $scopes */ + $scopes = $request->attributes->get(AgentRouteScope::ATTRIBUTE_PAYPAL_AGENT_SCOPE, []); + + $constraints = [ + new IssuedBy(self::JWT_EXPECTED_ISSUER), + new LooseValidAt(new NativeClock()), + new SignedWith(new Sha256(), InMemory::plainText(self::$PAYPAL_JWT)), + ]; + + if (!empty($scopes)) { + $constraints[] = new HasScopes($scopes); + } + + $this->JWTDecoder->validate($jwt, ...$constraints); + } + + private function resolveContextSource(string $token): AgentSource + { + try { + /** @var array{external_id: array{0: string}, sub: string, iat: \DateTimeInterface, exp: \DateTimeInterface, scope: list, debug_id?: string} $decoded */ + $decoded = $this->JWTDecoder->decode($token); // @phpstan-ignore varTag.type + } catch (JWTException $e) { + throw AgentException::unauthorized('Invalid JWT token', $e->getPrevious()); + } + + $definition = new DataValidationDefinition('paypal.agent_source'); + $definition + ->add('external_id', new NotBlank(), new Type('array'), new All([new NotBlank(), new Type('string'), new Regex('/^PayPal:.+$/')])) + ->add('sub', new NotBlank(), new Type('string'), new Uuid()) + ->add('iat', new NotBlank(), new Type(\DateTimeInterface::class)) + ->add('exp', new NotBlank(), new Type(\DateTimeInterface::class)) + ->add('scope', new Type('array'), new All([new Type('string'), new NotBlank()])) + ->add('debug_id', new Optional([new Type('string')])); + + try { + $this->validator->validate($decoded, $definition); + } catch (ConstraintViolationException $e) { + throw AgentException::unauthorized('Invalid JWT token', $e); + } + + return new AgentSource(self::extractPayPalMerchantId($decoded['external_id'][0]), $decoded['iat'], $decoded['exp'], $decoded['scope'], $decoded['sub'], $decoded['debug_id'] ?? null); + } + + private static function extractPayPalMerchantId(string $externalId): string + { + \preg_match('/^PayPal:\s*(.+)$/', $externalId, $matches); + + return $matches[1]; + } +} diff --git a/src/AgentCommerce/Routing/AgentRouteScope.php b/src/AgentCommerce/Routing/AgentRouteScope.php new file mode 100644 index 000000000..0dfa1af5a --- /dev/null +++ b/src/AgentCommerce/Routing/AgentRouteScope.php @@ -0,0 +1,62 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Routing; + +use Shopware\Core\Framework\Api\Context\AdminSalesChannelApiSource; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Routing\AbstractRouteScope; +use Shopware\Core\PlatformRequest; +use Shopware\Core\System\SalesChannel\SalesChannelContext; +use Symfony\Component\HttpFoundation\Request; + +/** + * @internal + */ +#[Package('checkout')] +class AgentRouteScope extends AbstractRouteScope +{ + final public const ATTRIBUTE_PAYPAL_AGENT_SCOPE = '_agentScope'; + final public const ID = 'paypal-agent'; + + /** + * @var array + * + * @deprecated tag:v6.7.0 - Will be natively typed + */ + protected $allowedPaths = ['api']; + + public function isAllowed(Request $request): bool + { + if (!$request->headers->has('Authorization')) { + return false; + } + + $context = $request->attributes->get(PlatformRequest::ATTRIBUTE_SALES_CHANNEL_CONTEXT_OBJECT); + + if (!$context instanceof SalesChannelContext) { + return false; + } + + $source = $context->getContext()->getSource(); + + if ($source instanceof AgentSource) { + return true; + } + + if ($source instanceof AdminSalesChannelApiSource && $source->getOriginalContext()->getSource() instanceof AgentSource) { + return true; + } + + return false; + } + + public function getId(): string + { + return self::ID; + } +} diff --git a/src/AgentCommerce/Routing/AgentSource.php b/src/AgentCommerce/Routing/AgentSource.php new file mode 100644 index 000000000..60153076c --- /dev/null +++ b/src/AgentCommerce/Routing/AgentSource.php @@ -0,0 +1,61 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Routing; + +use Shopware\Core\Framework\Api\Context\ContextSource; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\JsonSerializableTrait; + +/** + * @internal + */ +#[Package('checkout')] +class AgentSource implements ContextSource, \JsonSerializable +{ + use JsonSerializableTrait; + + final public const SCOPE_CART = 'cart'; + final public const SCOPE_CHECKOUT = 'checkout'; + + public string $type = AgentRouteScope::ID; + + private ?string $streamId = null; + + /** + * @param string[] $scope + */ + public function __construct( + public readonly string $merchantId, + public readonly \DateTimeInterface $issuedAt, + public readonly \DateTimeInterface $expiresAt, + public readonly array $scope, + public readonly string $salesChannelId, + public readonly ?string $debugId = null, + ) { + } + + public function hasScope(string $scope): bool + { + return \in_array($scope, $this->scope, true); + } + + public function isExpired(): bool + { + return $this->expiresAt < new \DateTimeImmutable(); + } + + public function getStreamId(): ?string + { + return $this->streamId; + } + + public function setStreamId(?string $streamId): void + { + $this->streamId = $streamId; + } +} diff --git a/src/AgentCommerce/SalesChannel/AbstractAgentCommerceRoute.php b/src/AgentCommerce/SalesChannel/AbstractAgentCommerceRoute.php new file mode 100644 index 000000000..fa826ab66 --- /dev/null +++ b/src/AgentCommerce/SalesChannel/AbstractAgentCommerceRoute.php @@ -0,0 +1,51 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\SalesChannel; + +use Shopware\Core\Framework\Api\Context\AdminSalesChannelApiSource; +use Shopware\Core\Framework\Context; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\System\SalesChannel\Context\SalesChannelContextService; +use Shopware\Core\System\SalesChannel\Context\SalesChannelContextServiceParameters; +use Shopware\Core\System\SalesChannel\SalesChannelContext; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\PaymentMethod; + +/** + * @internal + */ +#[Package('checkout')] +abstract class AbstractAgentCommerceRoute +{ + protected SalesChannelContextService $contextService; + + protected function createSalesChannelContext(string $token, string $salesChannelId, Context $context): SalesChannelContext + { + $source = $context->getSource(); + if ($source instanceof AdminSalesChannelApiSource) { + $context = $source->getOriginalContext(); + } + + return $this->contextService->get(new SalesChannelContextServiceParameters( + salesChannelId: $salesChannelId, + token: $token, + originalContext: $context + )); + } + + protected function createPaymentMethod(string $token, ?string $payerId = null): PaymentMethod + { + $method = new PaymentMethod(); + $method->setToken($token); + + if ($payerId) { + $method->setPayerId($payerId); + } + + return $method; + } +} diff --git a/src/AgentCommerce/SalesChannel/CheckoutRoute.php b/src/AgentCommerce/SalesChannel/CheckoutRoute.php new file mode 100644 index 000000000..0ddcfcc5b --- /dev/null +++ b/src/AgentCommerce/SalesChannel/CheckoutRoute.php @@ -0,0 +1,90 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\SalesChannel; + +use Shopware\Core\Checkout\Cart\SalesChannel\AbstractCartOrderRoute; +use Shopware\Core\Checkout\Cart\SalesChannel\CartService; +use Shopware\Core\Checkout\Payment\Cart\PaymentTransactionStruct; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Validation\DataBag\RequestDataBag; +use Shopware\Core\System\SalesChannel\SalesChannelContext; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\PayPalCart; +use Swag\PayPal\AgentCommerce\Exception\AgentException; +use Swag\PayPal\AgentCommerce\Routing\AgentSource; +use Swag\PayPal\AgentCommerce\SalesChannel\Response\AgentCartResponse; +use Swag\PayPal\AgentCommerce\Util\PayPalCartTransformer; +use Swag\PayPal\AgentCommerce\Validation\CartTokenValidator; +use Swag\PayPal\Checkout\Payment\Method\AbstractPaymentMethodHandler; +use Swag\PayPal\Checkout\Payment\PayPalPaymentHandler; +use Swag\PayPal\RestApi\V2\Resource\OrderResource; +use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Routing\Attribute\Route; + +/** + * @internal + */ +#[Package('checkout')] +#[Route(defaults: ['_routeScope' => ['paypal-agent'], '_agentScope' => [AgentSource::SCOPE_CART, AgentSource::SCOPE_CHECKOUT]])] +class CheckoutRoute extends AbstractAgentCommerceRoute +{ + public function __construct( + private readonly AbstractCartOrderRoute $orderRoute, + private readonly CartService $cartService, + private readonly OrderResource $orderResource, + private readonly PayPalPaymentHandler $paymentHandler, + private readonly PayPalCartTransformer $cartTransformer, + ) { + } + + #[Route('/api/paypal/v1/merchant-cart/{token}/checkout', name: 'api.paypal.merchant-cart.checkout', methods: [Request::METHOD_POST])] + public function checkout(string $token, Request $request, SalesChannelContext $context): AgentCartResponse + { + $extractedToken = CartTokenValidator::validateCartToken($token); + + $cart = $this->cartService->getCart($extractedToken, $context); + if (!$cart->getLineItems()->count()) { + // We don't create a cart with empty items. So it must be created. + throw AgentException::cartNotFound($token); + } + + $order = $this->orderRoute + ->order($cart, $context, new RequestDataBag($request->request->all())) + ->getOrder(); + + $primaryTransactionId = $order->getTransactions()?->last()?->getId(); + + // @deprecated tag:v11.0.0 - remove if condition with min-version of 6.7.1.0, keep content + if (\method_exists($order, 'getPrimaryTransactionId')) { + $primaryTransactionId = $order->getPrimaryTransactionId(); + } + + if (!$primaryTransactionId) { + throw AgentException::orderSystemError(); + } + + $body = \json_decode($request->getContent(), true, flags: \JSON_THROW_ON_ERROR); + $payPalOrder = $this->orderResource->get($body['payment_method']['token'], $context->getSalesChannelId()); + + $request->request->set(AbstractPaymentMethodHandler::PAYPAL_PAYMENT_ORDER_ID_INPUT_NAME, $payPalOrder->getId()); + + $payPalCart = $this->cartTransformer->convertToPayPalCart($cart, $context); + $payPalCart->setStatus(PayPalCart::STATUS__COMPLETE); + $payPalCart->setPaymentMethod($this->createPaymentMethod($payPalOrder->getId())); + + $response = $this->paymentHandler->pay($request, new PaymentTransactionStruct($primaryTransactionId), $context->getContext(), null); + + if ($response instanceof RedirectResponse) { + $payPalCart->setStatus(PayPalCart::STATUS__INCOMPLETE); + $payPalCart->setValidationStatus(PayPalCart::VALIDATION_STATUS__INVALID); + $payPalCart->getPaymentMethod()?->setApprovalUrl($response->getTargetUrl()); + } + + return new AgentCartResponse($payPalCart); + } +} diff --git a/src/AgentCommerce/SalesChannel/CreateCartRoute.php b/src/AgentCommerce/SalesChannel/CreateCartRoute.php new file mode 100644 index 000000000..2aed859b1 --- /dev/null +++ b/src/AgentCommerce/SalesChannel/CreateCartRoute.php @@ -0,0 +1,105 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\SalesChannel; + +use Shopware\Core\Checkout\Cart\Cart; +use Shopware\Core\Checkout\Cart\SalesChannel\CartService; +use Shopware\Core\Checkout\Customer\SalesChannel\AbstractRegisterRoute; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Validation\DataBag\RequestDataBag; +use Shopware\Core\System\SalesChannel\Context\SalesChannelContextService; +use Shopware\Core\System\SalesChannel\SalesChannelContext; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\PayPalCart; +use Shopware\PayPalSDK\Struct\V2\Patch; +use Swag\PayPal\AgentCommerce\Routing\AgentSource; +use Swag\PayPal\AgentCommerce\SalesChannel\Response\AgentCartResponse; +use Swag\PayPal\AgentCommerce\Util\PayPalCartFactory; +use Swag\PayPal\AgentCommerce\Util\PayPalCartTransformer; +use Swag\PayPal\AgentCommerce\Util\ShopwareCartTransformer; +use Swag\PayPal\OrdersApi\Builder\AbstractOrderBuilder; +use Swag\PayPal\RestApi\PartnerAttributionId; +use Swag\PayPal\RestApi\V2\Resource\OrderResource; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; + +/** + * @internal + */ +#[Package('checkout')] +#[Route(defaults: ['_routeScope' => ['paypal-agent'], '_agentScope' => [AgentSource::SCOPE_CART]])] +class CreateCartRoute extends AbstractAgentCommerceRoute +{ + public function __construct( + protected SalesChannelContextService $contextService, + private readonly CartService $cartService, + private readonly PayPalCartTransformer $payPalCartTransformer, + private readonly ShopwareCartTransformer $shopwareCartTransformer, + private readonly AbstractRegisterRoute $registerRoute, + private readonly AbstractOrderBuilder $orderBuilder, + private readonly OrderResource $orderResource, + ) { + } + + #[Route('/api/paypal/v1/merchant-cart', name: 'api.paypal.merchant-cart', methods: [Request::METHOD_POST])] + public function createCart(Request $request, SalesChannelContext $salesChannelContext): AgentCartResponse + { + $payPalCart = (new PayPalCartFactory())->create($request->getPayload()->all()); + if ($payPalCart->getCustomer() && !$salesChannelContext->getCustomer()) { + $salesChannelContext = $this->registerAndLoginCustomer($payPalCart, $salesChannelContext); + } + + $swCart = $this->cartService->createNew($salesChannelContext->getToken()); + $swCart = $this->cartService->add($swCart, $this->shopwareCartTransformer->getLineItems($payPalCart, $salesChannelContext), $salesChannelContext); + + $orderId = $this->upsertPayPalOrder($payPalCart, $swCart, $request->request->all(), $salesChannelContext); + + $createdPayPalCart = $this->payPalCartTransformer->convertToPayPalCart($swCart, $salesChannelContext, $payPalCart); + + $createdPayPalCart->setStatus($createdPayPalCart->getValidationStatus() === PayPalCart::VALIDATION_STATUS__VALID ? PayPalCart::STATUS__CREATED : PayPalCart::STATUS__INCOMPLETE); + $createdPayPalCart->setPaymentMethod($this->createPaymentMethod($orderId)); + + $response = new AgentCartResponse($createdPayPalCart); + if ($createdPayPalCart->getStatus() === PayPalCart::STATUS__CREATED) { + $response->setStatusCode(Response::HTTP_CREATED); + } + + return $response; + } + + private function registerAndLoginCustomer(PayPalCart $payPalCart, SalesChannelContext $salesChannelContext): SalesChannelContext + { + $customerData = $this->shopwareCartTransformer->extractCustomerData($payPalCart, $salesChannelContext->getSalesChannelId(), $salesChannelContext->getContext()); + + $this->registerRoute->register(new RequestDataBag($customerData), $salesChannelContext, false); + + return $this->createSalesChannelContext($salesChannelContext->getToken(), $salesChannelContext->getSalesChannelId(), $salesChannelContext->getContext()); + } + + private function upsertPayPalOrder(PayPalCart $payPalCart, Cart $swCart, array $requestData, SalesChannelContext $salesChannelContext): string + { + $order = $this->orderBuilder->getOrderFromCart($swCart, $salesChannelContext, new RequestDataBag($requestData)); + $orderId = $payPalCart->getPaymentMethod()?->getToken(); + + if ($orderId) { + $purchaseUnit = $order->getPurchaseUnits()->first(); + $purchaseUnitArray = \json_decode((string) \json_encode($purchaseUnit), true); + + $purchaseUnitPatch = new Patch(); + $purchaseUnitPatch->setOp(Patch::OPERATION_REPLACE); + $purchaseUnitPatch->setPath('/purchase_units/@reference_id==\'default\''); + $purchaseUnitPatch->setValue($purchaseUnitArray); + + $this->orderResource->update([$purchaseUnitPatch], $orderId, $salesChannelContext->getSalesChannelId(), PartnerAttributionId::PAYPAL_PPCP); + } else { + $orderId = $this->orderResource->create($order, $salesChannelContext->getSalesChannelId(), PartnerAttributionId::PAYPAL_PPCP)->getId(); + } + + return $orderId; + } +} diff --git a/src/AgentCommerce/SalesChannel/GetCartRoute.php b/src/AgentCommerce/SalesChannel/GetCartRoute.php new file mode 100644 index 000000000..89cc3ce71 --- /dev/null +++ b/src/AgentCommerce/SalesChannel/GetCartRoute.php @@ -0,0 +1,51 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\SalesChannel; + +use Shopware\Core\Checkout\Cart\SalesChannel\CartService; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\System\SalesChannel\SalesChannelContext; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\PayPalCart; +use Swag\PayPal\AgentCommerce\Exception\AgentException; +use Swag\PayPal\AgentCommerce\Routing\AgentSource; +use Swag\PayPal\AgentCommerce\SalesChannel\Response\AgentCartResponse; +use Swag\PayPal\AgentCommerce\Util\PayPalCartTransformer; +use Swag\PayPal\AgentCommerce\Validation\CartTokenValidator; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Routing\Attribute\Route; + +/** + * @internal + */ +#[Package('checkout')] +#[Route(defaults: ['_routeScope' => ['paypal-agent'], '_agentScope' => [AgentSource::SCOPE_CART]])] +class GetCartRoute +{ + public function __construct( + private readonly CartService $cartService, + private readonly PayPalCartTransformer $payPalCartTransformer, + ) { + } + + #[Route('/api/paypal/v1/merchant-cart/{token}', name: 'api.paypal.merchant-cart.get', methods: [Request::METHOD_GET])] + public function getCart(string $token, SalesChannelContext $salesChannelContext): AgentCartResponse + { + $extractedToken = CartTokenValidator::validateCartToken($token); + + $cart = $this->cartService->getCart($extractedToken, $salesChannelContext); + if (!$cart->getLineItems()->count()) { + // We don't create a cart with empty items. So it must be created. + throw AgentException::cartNotFound($token); + } + + $payPalCart = $this->payPalCartTransformer->convertToPayPalCart($cart, $salesChannelContext); + $payPalCart->setStatus($payPalCart->getValidationStatus() === PayPalCart::VALIDATION_STATUS__VALID ? PayPalCart::STATUS__READY : PayPalCart::STATUS__INCOMPLETE); + + return new AgentCartResponse($payPalCart); + } +} diff --git a/src/AgentCommerce/SalesChannel/Response/AgentCartResponse.php b/src/AgentCommerce/SalesChannel/Response/AgentCartResponse.php new file mode 100644 index 000000000..ec96f6850 --- /dev/null +++ b/src/AgentCommerce/SalesChannel/Response/AgentCartResponse.php @@ -0,0 +1,33 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\SalesChannel\Response; + +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\ArrayStruct; +use Shopware\Core\System\SalesChannel\StoreApiResponse; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\PayPalCart; + +/** + * @extends StoreApiResponse>> + */ +#[Package('checkout')] +final class AgentCartResponse extends StoreApiResponse +{ + public function __construct( + protected PayPalCart $cart, + ) { + parent::__construct( + new ArrayStruct($this->cart->jsonSerialize()) + ); + } + + public function getCart(): PayPalCart + { + return $this->cart; + } +} diff --git a/src/AgentCommerce/SalesChannel/UpdateCartRoute.php b/src/AgentCommerce/SalesChannel/UpdateCartRoute.php new file mode 100644 index 000000000..ea3520020 --- /dev/null +++ b/src/AgentCommerce/SalesChannel/UpdateCartRoute.php @@ -0,0 +1,157 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\SalesChannel; + +use Shopware\Core\Checkout\Cart\SalesChannel\CartService; +use Shopware\Core\Checkout\Customer\Aggregate\CustomerAddress\CustomerAddressCollection; +use Shopware\Core\Checkout\Customer\CustomerCollection; +use Shopware\Core\Checkout\Customer\CustomerEntity; +use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Validation\DataBag\RequestDataBag; +use Shopware\Core\Framework\Validation\Exception\ConstraintViolationException; +use Shopware\Core\System\SalesChannel\Context\SalesChannelContextService; +use Shopware\Core\System\SalesChannel\SalesChannel\ContextSwitchRoute; +use Shopware\Core\System\SalesChannel\SalesChannelContext; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\PayPalCart; +use Swag\PayPal\AgentCommerce\Exception\AgentException; +use Swag\PayPal\AgentCommerce\Routing\AgentSource; +use Swag\PayPal\AgentCommerce\SalesChannel\Response\AgentCartResponse; +use Swag\PayPal\AgentCommerce\Util\ShopwareCartTransformer; +use Swag\PayPal\AgentCommerce\Validation\CartTokenValidator; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; + +/** + * @internal + */ +#[Package('checkout')] +#[Route(defaults: ['_routeScope' => ['paypal-agent'], '_agentScope' => [AgentSource::SCOPE_CHECKOUT]])] +class UpdateCartRoute extends AbstractAgentCommerceRoute +{ + /** + * @param EntityRepository $customerRepository + * @param EntityRepository $customerAddressRepository + */ + public function __construct( + protected SalesChannelContextService $contextService, + private readonly ShopwareCartTransformer $shopwareCartTransformer, + private readonly CreateCartRoute $createCartRoute, + private readonly EntityRepository $customerRepository, + private readonly EntityRepository $customerAddressRepository, + private readonly CartService $cartService, + private readonly ContextSwitchRoute $contextSwitchRoute, + ) { + } + + #[Route('/api/paypal/v1/merchant-cart/{token}', name: 'api.paypal.merchant-cart.update', methods: [Request::METHOD_PUT])] + public function updateCart(string $token, Request $request, SalesChannelContext $salesChannelContext): AgentCartResponse + { + CartTokenValidator::validateCartToken($token); + + $payPalCart = (new PayPalCart())->assign($request->getPayload()->all()); + $salesChannelContext = $this->loginCustomer($payPalCart, $salesChannelContext); + + $this->cartService->deleteCart($salesChannelContext); + + $salesChannelContext = $this->changeShippingMethod($payPalCart, $salesChannelContext); + + $response = $this->createCartRoute->createCart($request, $salesChannelContext); + + if ($response->isSuccessful()) { + if ($response->getObject()->offsetGet('validation_status') === PayPalCart::VALIDATION_STATUS__VALID) { + $response->getObject()->offsetSet('validation_status', PayPalCart::STATUS__READY); + } + + $response->setStatusCode(Response::HTTP_OK); + } + + return $response; + } + + private function loginCustomer(PayPalCart $payPalCart, SalesChannelContext $salesChannelContext): SalesChannelContext + { + $customer = $salesChannelContext->getCustomer(); + if (!$customer instanceof CustomerEntity) { + return $salesChannelContext; + } + + if (!$payPalCart->getCustomer()) { + $this->customerRepository->delete([['id' => $customer->getId()]], $salesChannelContext->getContext()); + + return $this->createSalesChannelContext( + $salesChannelContext->getToken(), + $salesChannelContext->getSalesChannelId(), + $salesChannelContext->getContext() + ); + } + + $customerData = $this->shopwareCartTransformer->extractCustomerData($payPalCart, $salesChannelContext->getSalesChannelId(), $salesChannelContext->getContext()); + $customerData['id'] = $customer->getId(); + $customerData['shippingAddress']['id'] = $customer->getDefaultShippingAddressId(); + $customerData['defaultShippingAddress'] = $customerData['shippingAddress']; + + $toDeleteAddress = null; + if (isset($customerData['billingAddress'])) { + $customerData['billingAddress']['id'] = $customer->getDefaultBillingAddressId(); + $customerData['defaultBillingAddress'] = $customerData['billingAddress']; + } elseif ($customer->getDefaultShippingAddressId() !== $customer->getDefaultBillingAddressId()) { + $toDeleteAddress = [['id' => $customer->getDefaultBillingAddressId()]]; + + $customerData['defaultBillingAddressId'] = $customer->getDefaultShippingAddressId(); + } + + unset($customerData['shippingAddress'], $customerData['billingAddress']); + + $this->customerRepository->update([$customerData], $salesChannelContext->getContext()); + + if (!empty($toDeleteAddress)) { + $this->customerAddressRepository->delete($toDeleteAddress, $salesChannelContext->getContext()); + } + + return $this->createSalesChannelContext( + $salesChannelContext->getToken(), + $salesChannelContext->getSalesChannelId(), + $salesChannelContext->getContext() + ); + } + + private function changeShippingMethod(PayPalCart $payPalCart, SalesChannelContext $salesChannelContext): SalesChannelContext + { + $shippingOptions = $payPalCart->getAvailableShippingOptions(); + if (!$shippingOptions) { + return $salesChannelContext; + } + + foreach ($shippingOptions as $shippingOption) { + if (!$shippingOption->isSelected()) { + continue; + } + + if ($shippingOption->getId() === $salesChannelContext->getShippingMethod()->getId()) { + // Right shipping method already selected + break; + } + + try { + $token = $this->contextSwitchRoute->switchContext(new RequestDataBag([SalesChannelContextService::SHIPPING_METHOD_ID => $shippingOption->getId()]), $salesChannelContext)->getToken(); + } catch (ConstraintViolationException $e) { + throw AgentException::requiredFieldInvalid('availableShippingOption.id', $e->getViolations()->__toString()); + } + + return $this->createSalesChannelContext( + $token, + $salesChannelContext->getSalesChannelId(), + $salesChannelContext->getContext() + ); + } + + return $salesChannelContext; + } +} diff --git a/src/AgentCommerce/Subscriber/ProductFilterSubscriber.php b/src/AgentCommerce/Subscriber/ProductFilterSubscriber.php new file mode 100644 index 000000000..b9643706b --- /dev/null +++ b/src/AgentCommerce/Subscriber/ProductFilterSubscriber.php @@ -0,0 +1,41 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Subscriber; + +use Shopware\Core\Content\Product\Events\ProductGatewayCriteriaEvent; +use Shopware\Core\Framework\Api\Context\AdminSalesChannelApiSource; +use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter; +use Shopware\Core\Framework\Log\Package; +use Swag\PayPal\AgentCommerce\Routing\AgentSource; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * @internal + */ +#[Package('checkout')] +class ProductFilterSubscriber implements EventSubscriberInterface +{ + public static function getSubscribedEvents(): array + { + return [ + ProductGatewayCriteriaEvent::class => 'onProductGatewayCriteria', + ]; + } + + public function onProductGatewayCriteria(ProductGatewayCriteriaEvent $event): void + { + $source = $event->getContext()->getSource(); + if (!$source instanceof AgentSource + && (!$source instanceof AdminSalesChannelApiSource || !($source = $source->getOriginalContext()->getSource()) instanceof AgentSource)) { + return; + } + + $event->getCriteria() + ->addFilter(new EqualsFilter('streams.id', $source->getStreamId())); + } +} diff --git a/src/AgentCommerce/Subscriber/WebhookSubscriber.php b/src/AgentCommerce/Subscriber/WebhookSubscriber.php new file mode 100644 index 000000000..791beec83 --- /dev/null +++ b/src/AgentCommerce/Subscriber/WebhookSubscriber.php @@ -0,0 +1,123 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Subscriber; + +use Shopware\Administration\Notification\NotificationCollection; +use Shopware\Core\Framework\Api\Context\AdminApiSource; +use Shopware\Core\Framework\Context; +use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository; +use Shopware\Core\Framework\DataAbstractionLayer\EntityWriteResult; +use Shopware\Core\Framework\DataAbstractionLayer\Event\EntityWrittenEvent; +use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria; +use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Uuid\Uuid; +use Shopware\Core\System\SalesChannel\SalesChannelCollection; +use Swag\PayPal\AgentCommerce\Exception\HoneyWebhookException; +use Swag\PayPal\AgentCommerce\HoneyWebhookService; +use Swag\PayPal\SwagPayPal; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * @internal + */ +#[Package('checkout')] +class WebhookSubscriber implements EventSubscriberInterface +{ + /** + * @param EntityRepository $salesChannelRepository + * @param EntityRepository $notificationRepository + */ + public function __construct( + private readonly EntityRepository $salesChannelRepository, + private readonly HoneyWebhookService $webhookService, + private readonly EntityRepository $notificationRepository, // @phpstan-ignore parameter.deprecatedClass, property.deprecatedClass + ) { + } + + public static function getSubscribedEvents(): array + { + return [ + 'sales_channel.written' => 'handleWebhookLifecycle', + ]; + } + + public function handleWebhookLifecycle(EntityWrittenEvent $event): void + { + $mapped = []; + foreach ($event->getWriteResults() as $writeResult) { + /** @var string $id */ + $id = $writeResult->getPrimaryKey(); + if ($writeResult->getOperation() === EntityWriteResult::OPERATION_DELETE) { + $mapped[$id] = false; + + continue; + } + + $active = $writeResult->getProperty('active'); + if ($active === null) { + continue; + } + + $mapped[$id] = $active; + } + + if (empty($mapped)) { + return; + } + + $criteria = new Criteria(array_keys($mapped)); + $criteria->addFilter(new EqualsFilter('typeId', SwagPayPal::SALES_CHANNEL_TYPE_AGENT_COMMERCE)); + + $userId = null; + $context = $event->getContext(); + $source = $context->getSource(); + if ($source instanceof AdminApiSource) { + $userId = $source->getUserId(); + } + + $salesChannelIds = $this->salesChannelRepository->searchIds($criteria, $context); + foreach ($mapped as $id => $active) { + try { + if ($active && $salesChannelIds->has($id)) { + $result = $this->webhookService->register($id, $context); + } else { + $result = $this->webhookService->deregister($id); + } + + if (!$userId) { + continue; + } + + $notification = [ + 'id' => Uuid::randomHex(), + 'status' => $result->success ? 'success' : 'error', + 'message' => 'PayPal agent commerce: ' . $result->message, + 'requiredPrivileges' => [], + 'createdByUserId' => $userId, + ]; + } catch (HoneyWebhookException $e) { + if ($e->getErrorCode() === HoneyWebhookException::NOT_REGISTERED) { + continue; + } + + $notification = [ + 'id' => Uuid::randomHex(), + 'status' => 'error', + 'message' => 'PayPal agent commerce: ' . $e->getErrorCode(), + 'requiredPrivileges' => [], + 'createdByUserId' => $userId, + ]; + } + + $context->scope(Context::SYSTEM_SCOPE, function (Context $context) use ($notification): void { + $this->notificationRepository->create([$notification], $context); + }); + } + } +} diff --git a/src/AgentCommerce/Util/FaviconLoader.php b/src/AgentCommerce/Util/FaviconLoader.php new file mode 100644 index 000000000..7efe27e07 --- /dev/null +++ b/src/AgentCommerce/Util/FaviconLoader.php @@ -0,0 +1,49 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Util; + +use Shopware\Core\Framework\Context; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Util\Random; +use Shopware\Core\System\SalesChannel\Context\SalesChannelContextService; +use Shopware\Core\System\SalesChannel\Context\SalesChannelContextServiceParameters; +use Shopware\Storefront\Theme\AbstractResolvedConfigLoader; +use Shopware\Storefront\Theme\ConfigLoader\AbstractAvailableThemeProvider; +use Swag\PayPal\AgentCommerce\Exception\HoneyWebhookException; + +/** + * @internal + */ +#[Package('checkout')] +class FaviconLoader +{ + public function __construct( + private readonly AbstractAvailableThemeProvider $themeLoader, + private readonly AbstractResolvedConfigLoader $configService, + private readonly SalesChannelContextService $contextService + ) { + } + + public function loadFaviconLink(string $salesChannelId, Context $context): string + { + $themeId = $this->themeLoader->load($context, true)[$salesChannelId] ?? null; + if ($themeId === null) { + throw HoneyWebhookException::storefrontSalesChannelNotFound(); + } + + $salesChannelContext = $this->contextService->get(new SalesChannelContextServiceParameters( + salesChannelId: $salesChannelId, + token: Random::getAlphanumericString(32), + originalContext: $context + )); + + $config = $this->configService->load($themeId, $salesChannelContext); + + return $config['sw-logo-favicon'] ?? ''; + } +} diff --git a/src/AgentCommerce/Util/PayPalCartFactory.php b/src/AgentCommerce/Util/PayPalCartFactory.php new file mode 100644 index 000000000..5f025938a --- /dev/null +++ b/src/AgentCommerce/Util/PayPalCartFactory.php @@ -0,0 +1,96 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Util; + +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Uuid\Uuid; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\Address; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\CartItemCollection; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\PayPalCart; +use Swag\PayPal\AgentCommerce\Exception\AgentException; + +/** + * @internal + */ +#[Package('checkout')] +class PayPalCartFactory +{ + public function create(array $data): PayPalCart + { + $payPalCart = (new PayPalCart())->assign($data); + + $this->validateCustomerData($payPalCart); + + if (!$payPalCart->isset('items') || !$payPalCart->getItems()->count()) { + throw AgentException::requiredFieldsMissing('cart.items'); + } + + $this->validateItems($payPalCart->getItems()); + + return $payPalCart; + } + + private function validateCustomerData(PayPalCart $cart): void + { + $customer = $cart->getCustomer(); + if (!$customer) { + return; + } + + if (!$customer->getEmailAddress()) { + throw AgentException::requiredFieldsMissing('cart.customer.emailAddress'); + } + + if (!$customer->isset('name') || !$customer->getName()->isset('givenName') || !$customer->getName()->isset('surname')) { + throw AgentException::requiredFieldsMissing('cart.customer.name'); + } + + $shippingAddress = $cart->getShippingAddress(); + if (!$shippingAddress instanceof Address) { + throw AgentException::requiredFieldsMissing('cart.shippingAddress'); + } + + $this->validateAddress($shippingAddress); + + if ($cart->getBillingAddress()) { + $this->validateAddress($cart->getBillingAddress()); + } + } + + private function validateAddress(Address $address): void + { + if (!$address->getAddressLine1()) { + throw AgentException::requiredFieldsMissing('address.addressLine1'); + } + + if (!$address->getAdminArea2()) { + throw AgentException::requiredFieldsMissing('address.adminArea2'); + } + + if (!$address->isset('countryCode')) { + throw AgentException::requiredFieldsMissing('address.countryCode'); + } + } + + private function validateItems(CartItemCollection $items): void + { + foreach ($items as $key => $item) { + if (!$item->isset('variantId')) { + throw AgentException::requiredFieldsMissing(\sprintf('cart.items.%s.variantId', $key)); + } + + if (!Uuid::isValid($item->getVariantId() ?? '')) { + throw AgentException::requiredFieldInvalid(\sprintf('cart.items.%s.variantId', $key), 'Not a valid UUID'); + } + + if (!$item->isset('quantity')) { + throw AgentException::requiredFieldsMissing(\sprintf('cart.items.%s.quantity', $key)); + } + } + } +} diff --git a/src/AgentCommerce/Util/PayPalCartTransformer.php b/src/AgentCommerce/Util/PayPalCartTransformer.php new file mode 100644 index 000000000..84fa4cf83 --- /dev/null +++ b/src/AgentCommerce/Util/PayPalCartTransformer.php @@ -0,0 +1,346 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Util; + +use Shopware\Core\Checkout\Cart\Cart; +use Shopware\Core\Checkout\Cart\Delivery\Struct\DeliveryDate; +use Shopware\Core\Checkout\Cart\Delivery\Struct\DeliveryTime; +use Shopware\Core\Checkout\Cart\LineItem\LineItem; +use Shopware\Core\Checkout\Customer\Aggregate\CustomerAddress\CustomerAddressEntity; +use Shopware\Core\Checkout\Customer\CustomerEntity; +use Shopware\Core\Checkout\Shipping\SalesChannel\AbstractShippingMethodRoute; +use Shopware\Core\Content\Product\ProductCollection; +use Shopware\Core\Framework\Context; +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\DataAbstractionLayer\Search\Filter\NotFilter; +use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\RangeFilter; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Uuid\Uuid; +use Shopware\Core\System\Country\CountryCollection; +use Shopware\Core\System\SalesChannel\SalesChannelContext; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\Address; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\AppliedCoupon; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\AppliedCouponCollection; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\BillingAddress; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\CartItem; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\CartItemCollection; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\CartTotals; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\Customer; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\Money; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\PayPalCart; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\Phone; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\Referral\CustomerName; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\ShippingAddress; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\ShippingOption; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\ShippingOptionCollection; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\ValidationIssueCollection; +use Swag\PayPal\AgentCommerce\Exception\AgentException; +use Swag\PayPal\AgentCommerce\Validation\ValidationIssues; +use Symfony\Component\HttpFoundation\Request; + +/** + * @internal + */ +#[Package('checkout')] +class PayPalCartTransformer +{ + /** + * @param EntityRepository $productRepository + * @param EntityRepository $countryRepository + */ + public function __construct( + private readonly EntityRepository $productRepository, + private readonly EntityRepository $countryRepository, + private readonly AbstractShippingMethodRoute $shippingMethodRoute, + private readonly ValidationIssues $validationIssues, + ) { + } + + public function convertToPayPalCart(Cart $cart, SalesChannelContext $context, ?PayPalCart $initialCart = null): PayPalCart + { + $payPalCart = new PayPalCart(); + + ['validationIssues' => $issues, 'status' => $status] = $this->convertToValidationIssues($cart, $initialCart?->getItems() ?? new CartItemCollection(), $context); + + $customer = $context->getCustomer(); + $shippingAddress = $this->convertAddress($customer?->getDefaultShippingAddress(), ShippingAddress::class, $context->getContext()); + $billingAddress = $this->convertAddress($customer?->getDefaultBillingAddress(), BillingAddress::class, $context->getContext()); + + $payPalCart->setId('CART-' . $cart->getToken()); + $payPalCart->setItems($this->convertToCartItems($cart->getLineItems()->filterFlatByType(LineItem::PRODUCT_LINE_ITEM_TYPE), $context)); + $payPalCart->setAppliedCoupons($this->convertToAppliedCoupons($cart->getLineItems()->filterFlatByType(LineItem::PROMOTION_LINE_ITEM_TYPE), $context)); + $payPalCart->setAvailableShippingOptions($this->convertToAvailableShippingMethods($cart, $context)); + $payPalCart->setValidationIssues($issues); + $payPalCart->setValidationStatus($status); + $payPalCart->setCustomer($this->convertCustomer($customer)); + $payPalCart->setShippingAddress($shippingAddress); + $payPalCart->setBillingAddress($billingAddress); + + $payPalCart->setTotals($this->createTotals($cart, $context)); + + return $payPalCart; + } + + /** + * @param LineItem[] $lineItems + */ + public function convertToCartItems(array $lineItems, SalesChannelContext $context): CartItemCollection + { + $items = new CartItemCollection(); + + foreach ($lineItems as $lineItem) { + if (!$lineItem->getPrice()) { + continue; + } + + $itemPrice = new Money(); + $itemPrice->setValue((string) $lineItem->getPrice()->getUnitPrice()); + $itemPrice->setCurrencyCode($context->getCurrency()->getIsoCode()); + + $cartItem = new CartItem(); + // itemId will be removed in the future. + $cartItem->setItemId($lineItem->getReferencedId()); + $cartItem->setVariantId($lineItem->getReferencedId()); + $cartItem->setParentId($lineItem->getPayloadValue('parentId')); // @phpstan-ignore method.deprecated + $cartItem->setQuantity($lineItem->getQuantity()); + $cartItem->setName($lineItem->getLabel()); + $cartItem->setPrice($itemPrice); + + $items->add($cartItem); + } + + return $items; + } + + public function convertToAvailableShippingMethods(Cart $cart, SalesChannelContext $context): ShippingOptionCollection + { + $availableShippingMethods = new ShippingOptionCollection(); + + $selectedMethods = []; + foreach ($cart->getDeliveries() as $delivery) { + $selectedMethods[$delivery->getShippingMethod()->getId()] ??= 0; + $selectedMethods[$delivery->getShippingMethod()->getId()] += $delivery->getShippingCosts()->getTotalPrice(); + } + + $shippingCriteria = new Criteria(); + $shippingCriteria->addAssociations(['appShippingMethod.app', 'deliveryTime']); + $shippingMethods = $this->shippingMethodRoute->load(new Request(), $context, $shippingCriteria)->getShippingMethods(); + foreach ($shippingMethods as $shippingMethod) { + $shippingOption = new ShippingOption(); + $shippingOption->setId($shippingMethod->getId()); + $shippingOption->setName($shippingMethod->getTranslation('name')); + $shippingOption->setDescription($shippingMethod->getTranslation('description')); + $shippingOption->setIsSelected(false); + + if ($shippingMethod->getDeliveryTime()) { + $shippingOption->setName(\sprintf('%s (%s)', $shippingOption->getName(), $shippingMethod->getDeliveryTime()->getTranslation('name'))); + $deliveryTime = DeliveryDate::createFromDeliveryTime(DeliveryTime::createFromEntity($shippingMethod->getDeliveryTime())); + + $shippingOption->setEstimatedDelivery($deliveryTime->getLatest()->format('Y-m-d')); + } + + if (\array_key_exists($shippingMethod->getId(), $selectedMethods)) { + $price = new Money(); + $price->setValue((string) $selectedMethods[$shippingMethod->getId()]); + $price->setCurrencyCode($context->getCurrency()->getIsoCode()); + + $shippingOption->setIsSelected(true); + $shippingOption->setPrice($price); + } + + $availableShippingMethods->add($shippingOption); + } + + return $availableShippingMethods; + } + + /** + * @return array{validationIssues: ValidationIssueCollection, status: string} + */ + public function convertToValidationIssues(Cart $cart, CartItemCollection $cartItems, SalesChannelContext $context): array + { + $status = PayPalCart::VALIDATION_STATUS__VALID; + $errors = new ValidationIssueCollection(); + + foreach ($cart->getErrors() as $error) { + if (!$error->blockOrder()) { + // Not errors we want to add here + continue; + } + + $errors->add($this->validationIssues->cartError($error, $context->getLanguageInfo()->localeCode)); + } + + $lineItems = $cart->getLineItems()->filterFlatByType(LineItem::PRODUCT_LINE_ITEM_TYPE); + + $restockProducts = []; + $productIds = []; + foreach ($lineItems as $lineItem) { + if ($lineItem->getReferencedId() !== null && Uuid::isValid($lineItem->getReferencedId())) { + $productIds[] = $lineItem->getReferencedId(); + } + } + + if (!empty($productIds)) { + $criteria = new Criteria($productIds); + $criteria->addFilter( + new RangeFilter('stock', [RangeFilter::LTE => 0]), + new NotFilter('AND', [new EqualsFilter('restockTime', null)]) + ); + $restockProducts = $this->productRepository->search($criteria, $context->getContext())->getElements(); + } + + $mapped = []; + foreach ($cartItems as $cartItem) { + $mapped[$cartItem->getVariantId()] = $cartItem; + } + + foreach ($lineItems as $lineItem) { + $stock = $lineItem->getPayloadValue('stock'); // @phpstan-ignore method.deprecated + if ($stock !== null && $stock < $lineItem->getQuantity()) { + $issue = $this->validationIssues->outOfStock($lineItem, $restockProducts[$lineItem->getReferencedId()] ?? null, $context->getCurrency()); + + $errors->add($issue); + } + + $initItem = $mapped[$lineItem->getReferencedId()] ?? null; + $initPrice = $initItem?->getPrice()?->getValue(); + if ($initPrice !== null && (float) $initPrice < $lineItem->getPrice()?->getUnitPrice()) { + $errors->add($this->validationIssues->changedPrice($lineItem, $initPrice, $context->getCurrency(), $context->getItemRounding())); + } + } + + // Age verification would be "REQUIRES_ADDITIONAL_INFORMATION" + if ($errors->count()) { + $status = PayPalCart::VALIDATION_STATUS__INVALID; + } + + return ['validationIssues' => $errors, 'status' => $status]; + } + + public function convertCustomer(?CustomerEntity $customerEntity): ?Customer + { + if (!$customerEntity) { + return null; + } + + $customer = new Customer(); + $name = new CustomerName(); + $name->setGivenName($customerEntity->getFirstName()); + $name->setSurname($customerEntity->getLastName()); + + $customer->setName($name); + $customer->setEmailAddress($customerEntity->getEmail()); + + $phoneNumber = $customerEntity->getDefaultShippingAddress()?->getPhoneNumber(); + if ($phoneNumber && Phone::isValidPhoneNumber($phoneNumber)) { + $phone = new Phone(); + $phone->setPhoneNumber($phoneNumber); + $customer->setPhone($phone); + } + + return $customer; + } + + /** + * @template TAddress as Address + * + * @param class-string $className + * + * @return TAddress + */ + public function convertAddress(?CustomerAddressEntity $addressEntity, string $className, Context $context): ?Address + { + if (!$addressEntity) { + return null; + } + + $iso = $addressEntity->getCountry()?->getIso(); + if (!$iso) { + $criteria = new Criteria([$addressEntity->getCountryId()]); + $criteria->addFields(['iso']); + + $iso = $this->countryRepository->search($criteria, $context)->first()?->get('iso'); + if (!$iso) { + throw AgentException::requiredFieldInvalid('address.countryCode', 'Country not found'); + } + } + + $address = new $className(); + $address->setCountryCode($iso); + $address->setPostalCode($addressEntity->__isset('zipcode') ? $addressEntity->getZipcode() : null); + $address->setAddressLine1($addressEntity->__isset('street') ? $addressEntity->getStreet() : null); + $address->setAdminArea2($addressEntity->__isset('city') ? $addressEntity->getCity() : null); + + return $address; + } + + public function createTotals(Cart $cart, SalesChannelContext $context): CartTotals + { + $iso = $context->getCurrency()->getIsoCode(); + $cartPrice = $cart->getPrice(); + + $subtotal = new Money(); + $subtotal->setValue((string) $cartPrice->getPositionPrice()); + $subtotal->setCurrencyCode($iso); + + $shipping = new Money(); + $shipping->setValue((string) $cart->getDeliveries()->getShippingCosts()->sum()->getTotalPrice()); + $shipping->setCurrencyCode($iso); + + $tax = new Money(); + $tax->setValue((string) $cartPrice->getCalculatedTaxes()->getAmount()); + $tax->setCurrencyCode($iso); + + $total = new Money(); + $total->setValue((string) $cartPrice->getTotalPrice()); + $total->setCurrencyCode($iso); + + // TODO: discount need to be done, when coupons are implemented + + $totals = new CartTotals(); + $totals->setSubtotal($subtotal); + $totals->setShipping($shipping); + $totals->setTax($tax); + $totals->setTotal($total); + + return $totals; + } + + /** + * @param LineItem[] $lineItems + */ + public function convertToAppliedCoupons(array $lineItems, SalesChannelContext $context): ?AppliedCouponCollection + { + if (empty($lineItems)) { + return null; + } + + $appliedCoupons = new AppliedCouponCollection(); + foreach ($lineItems as $lineItem) { + if (!$lineItem->getPrice()) { + continue; + } + + $discount = new Money(); + $discount->setValue((string) $lineItem->getPrice()->getTotalPrice()); + $discount->setCurrencyCode($context->getCurrency()->getIsoCode()); + + $coupon = new AppliedCoupon(); + $coupon->setCode($lineItem->getPayloadValue('code')); // @phpstan-ignore method.deprecated + $coupon->setDescription($lineItem->getDescription()); + $coupon->setDiscountAmount($discount); + + $appliedCoupons->add($coupon); + } + + return $appliedCoupons->count() ? $appliedCoupons : null; + } +} diff --git a/src/AgentCommerce/Util/ShopwareCartTransformer.php b/src/AgentCommerce/Util/ShopwareCartTransformer.php new file mode 100644 index 000000000..1e93534bd --- /dev/null +++ b/src/AgentCommerce/Util/ShopwareCartTransformer.php @@ -0,0 +1,140 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Util; + +use Shopware\Core\Checkout\Cart\LineItem\LineItem; +use Shopware\Core\Checkout\Cart\LineItemFactoryHandler\ProductLineItemFactory; +use Shopware\Core\Checkout\Customer\Aggregate\CustomerGroup\CustomerGroupCollection; +use Shopware\Core\Checkout\Promotion\Cart\PromotionItemBuilder; +use Shopware\Core\Framework\Context; +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\Framework\Uuid\Uuid; +use Shopware\Core\System\Country\CountryCollection; +use Shopware\Core\System\SalesChannel\SalesChannelContext; +use Shopware\Core\System\Salutation\SalutationCollection; +use Shopware\Core\System\Salutation\SalutationDefinition; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\Address; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\Coupon; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\CouponCollection; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\Customer; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\PayPalCart; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\Phone; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\Referral\CustomerName; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\ShippingAddress; +use Swag\PayPal\AgentCommerce\Exception\AgentException; + +/** + * @internal + */ +#[Package('checkout')] +class ShopwareCartTransformer +{ + /** + * @param EntityRepository $countryRepository + * @param EntityRepository $salutationRepository + * @param EntityRepository $groupRepository + */ + public function __construct( + private readonly EntityRepository $countryRepository, + private readonly EntityRepository $salutationRepository, + private readonly EntityRepository $groupRepository, + private readonly ProductLineItemFactory $lineItemFactory, + private readonly PromotionItemBuilder $promotionItemBuilder, + ) { + } + + /** + * @return array{firstName: string, lastName: string, email: string|null, salesChannelId: string, groupId: string|null, shippingAddress: array, guest: true, billingAddress?: array} + */ + public function extractCustomerData(PayPalCart $cart, string $salesChannelId, Context $context): array + { + /** @var Customer $customer */ + $customer = $cart->getCustomer(); + /** @var ShippingAddress $shippingAddress */ + $shippingAddress = $cart->getShippingAddress(); + + $groupCriteria = new Criteria(); + $groupCriteria->addFilter(new EqualsFilter('salesChannels.id', $salesChannelId)); + + $options = [ + 'firstName' => $customer->getName()->getGivenName(), + 'lastName' => $customer->getName()->getSurname(), + 'email' => $customer->getEmailAddress(), + 'salesChannelId' => $salesChannelId, + 'groupId' => $this->groupRepository->searchIds($groupCriteria, $context)->firstId(), + 'shippingAddress' => $this->formatAddress($shippingAddress, $customer->getName(), $customer->getPhone(), $context), + 'guest' => true, + ]; + + if ($cart->getBillingAddress()) { + $options['billingAddress'] = $this->formatAddress($cart->getBillingAddress(), $customer->getName(), $customer->getPhone(), $context); + } + + return $options; + } + + /** + * @return LineItem[] + */ + public function getLineItems(PayPalCart $payPalCart, SalesChannelContext $salesChannelContext): array + { + $lineItems = []; + foreach ($payPalCart->getItems() as $item) { + $lineItems[] = $this->lineItemFactory->create(['id' => $item->getVariantId(), 'quantity' => $item->getQuantity()], $salesChannelContext); + } + + if ($payPalCart->isset('coupons')) { + $lineItems = array_merge($lineItems, $this->handleCoupons($payPalCart->getCoupons())); + } + + return $lineItems; + } + + private function formatAddress(Address $address, CustomerName $name, ?Phone $phone, Context $context): array + { + $criteria = (new Criteria())->addFilter(new EqualsFilter('iso', $address->getCountryCode())); + $countryId = $this->countryRepository->searchIds($criteria, $context)->firstId(); + + if (!$countryId) { + throw AgentException::requiredFieldInvalid('address.countryCode', 'Country not found'); + } + + $criteria = (new Criteria())->addFilter(new EqualsFilter('salutationKey', SalutationDefinition::NOT_SPECIFIED)); + $salutationId = $this->salutationRepository->searchIds($criteria, $context)->firstId(); + + return [ + 'id' => Uuid::randomHex(), + 'salutationId' => $salutationId, + 'countryId' => $countryId, + 'firstName' => $name->getGivenName(), + 'lastName' => $name->getSurname(), + 'zipcode' => $address->getPostalCode(), + 'city' => $address->getAdminArea2(), + 'street' => $address->getAddressLine1(), + 'phoneNumber' => $phone?->getFullPhoneNumber(), + ]; + } + + /** + * @return LineItem[] + */ + private function handleCoupons(CouponCollection $coupons): array + { + $items = []; + foreach ($coupons as $coupon) { + if ($coupon->getAction() === Coupon::APPLY) { + $items[] = $this->promotionItemBuilder->buildPlaceholderItem($coupon->getCode()); + } + } + + return $items; + } +} diff --git a/src/AgentCommerce/Validation/CartTokenValidator.php b/src/AgentCommerce/Validation/CartTokenValidator.php new file mode 100644 index 000000000..1c8c37f85 --- /dev/null +++ b/src/AgentCommerce/Validation/CartTokenValidator.php @@ -0,0 +1,29 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Validation; + +use Shopware\Core\Framework\Log\Package; +use Swag\PayPal\AgentCommerce\Exception\AgentException; + +/** + * @internal + */ +#[Package('checkout')] +class CartTokenValidator +{ + public const REGEX = 'CART-(\w+)'; + + public static function validateCartToken(string $cartToken): string + { + if (!\preg_match(\sprintf('/^%s$/', self::REGEX), $cartToken, $matches)) { + throw AgentException::invalidCartId(); + } + + return $matches[1]; + } +} diff --git a/src/AgentCommerce/Validation/ValidationIssues.php b/src/AgentCommerce/Validation/ValidationIssues.php new file mode 100644 index 000000000..d63a7830f --- /dev/null +++ b/src/AgentCommerce/Validation/ValidationIssues.php @@ -0,0 +1,176 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Validation; + +use Shopware\Core\Checkout\Cart\Address\Error\AddressValidationError; +use Shopware\Core\Checkout\Cart\Address\Error\BillingAddressBlockedError; +use Shopware\Core\Checkout\Cart\Address\Error\ShippingAddressBlockedError; +use Shopware\Core\Checkout\Cart\Error\Error; +use Shopware\Core\Checkout\Cart\LineItem\LineItem; +use Shopware\Core\Checkout\Cart\Price\CashRounding; +use Shopware\Core\Content\Product\Cart\MinOrderQuantityError; +use Shopware\Core\Content\Product\Cart\ProductNotFoundError; +use Shopware\Core\Content\Product\Cart\ProductOutOfStockError; +use Shopware\Core\Content\Product\Cart\ProductStockReachedError; +use Shopware\Core\Content\Product\Cart\PurchaseStepsError; +use Shopware\Core\Content\Product\ProductEntity; +use Shopware\Core\Framework\Adapter\Translation\AbstractTranslator; +use Shopware\Core\Framework\DataAbstractionLayer\Pricing\CashRoundingConfig; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\System\Currency\CurrencyEntity; +use Shopware\PayPalSDK\Builder\AgenticCommerce\V1\ValidationIssueBuilder; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\Context\InventoryIssueContext; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\Context\PricingErrorContext; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\Referral\MetaData; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\ResolutionOption; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\ValidationIssue; + +#[Package('checkout')] +class ValidationIssues +{ + /** + * @internal + */ + public function __construct( + private readonly AbstractTranslator $translator, + ) { + } + + public function outOfStock(LineItem $item, ?ProductEntity $restockProduct, CurrencyEntity $currency): ValidationIssue + { + $stock = $item->getPayloadValue('stock'); // @phpstan-ignore method.deprecated + + $builder = new ValidationIssueBuilder(); + $builder + ->withCode(ValidationIssue::CODE__INVENTORY_ISSUE) + ->withType(ValidationIssue::TYPE__BUSINESS_RULE) + ->withMessage('Product stock insufficient') + ->withUserMessage($this->translator->trans('paypal.agentCommerce.validationIssue.userMessage.outOfStock', ['%name%' => $item->getLabel(), '%count%' => $stock])) + ->withItemId($item->getReferencedId() ?? '') + ->addResolutionOption() + ->withAction(ResolutionOption::ACTION__REMOVE_ITEM) + ->withLabel($this->translator->trans('paypal.agentCommerce.validationIssue.resolutionOption.removeLabel')) + ->withMetadata() + ->withCostImpact('-' . $currency->getSymbol() . $item->getPrice()?->getTotalPrice()) + ->withPriority(MetaData::PRIORITY__LOW) + ->end() + ->end(); + + $inventoryContext = new InventoryIssueContext(); + $inventoryContext->setSpecificIssue($stock > 0 ? InventoryIssueContext::ISSUE__INSUFFICIENT_INVENTORY : InventoryIssueContext::ISSUE__ITEM_OUT_OF_STOCK); + $inventoryContext->setAvailableQuantity($stock); + $inventoryContext->setRequestedQuantity($item->getQuantity()); + + $wait = $builder->addResolutionOption() + ->withAction(ResolutionOption::ACTION__WAIT_FOR_RESTOCK) + ->withLabel($this->translator->trans('paypal.agentCommerce.validationIssue.resolutionOption.waitRestockLabel', ['%name%' => $item->getLabel()])); + + if ($restockProduct) { + $wait->withMetadata() + ->withEstimatedTime($this->translator->trans('paypal.agentCommerce.validationIssue.resolutionOption.estimatedTime', ['%days%' => $restockProduct->getRestockTime()])) + ->withPriority(MetaData::PRIORITY__MEDIUM); + $inventoryContext->setRestockDate(\date('Y-m-d\T00:00:00', (int) strtotime('+' . $restockProduct->getRestockTime() . ' days'))); + } + + $builder->withContext($inventoryContext); + + return $builder->build(); + } + + /** + * @param numeric-string $initPrice + */ + public function changedPrice(LineItem $lineItem, string $initPrice, CurrencyEntity $currency, CashRoundingConfig $roundingConfig): ValidationIssue + { + $unitPrice = (string) $lineItem->getPrice()?->getUnitPrice(); + $priceDiff = (new CashRounding())->cashRound((float) $unitPrice - (float) $initPrice, $roundingConfig); + + if ($priceDiff <= 0) { + throw new \RuntimeException('Init price need to be lower then actual price'); + } + + $context = new PricingErrorContext(); + $context->setOriginalPrice($initPrice); + $context->setCurrentPrice($unitPrice); + $context->setCurrencyCode($currency->getIsoCode()); + $context->setPriceChangeReason(PricingErrorContext::PRICE_CHANGE_REASON__COMPONENT_COST_INCREASE); + $context->setPriceIncrease((string) $priceDiff); + + $builder = new ValidationIssueBuilder(); + $builder + ->withCode(ValidationIssue::CODE__PRICING_ERROR) + ->withType(ValidationIssue::TYPE__BUSINESS_RULE) + ->withMessage('Product price has changed') + ->withUserMessage($this->translator->trans('paypal.agentCommerce.validationIssue.userMessage.priceChanged', ['%name%' => $lineItem->getLabel(), '%oldPrice%' => $initPrice, '%newPrice%' => $unitPrice])) + ->withItemId($lineItem->getReferencedId() ?? '') + ->withContext($context); + + $builder->addResolutionOption() + ->withAction(ResolutionOption::ACTION__ACCEPT_NEW_PRICE) + ->withLabel($this->translator->trans('paypal.agentCommerce.validationIssue.resolutionOption.acceptLabel', ['%option%' => $unitPrice])) + ->withMetadata() + ->withCostImpact('+' . $currency->getSymbol() . $priceDiff) + ->withPriority(MetaData::PRIORITY__HIGH); + + $builder->addResolutionOption() + ->withAction(ResolutionOption::ACTION__REMOVE_ITEM) + ->withLabel($this->translator->trans('paypal.agentCommerce.validationIssue.resolutionOption.removeLabel')) + ->withMetadata() + ->withCostImpact('-' . $currency->getSymbol() . $initPrice) + ->withPriority(MetaData::PRIORITY__MEDIUM); + + return $builder->build(); + } + + public function cartError(Error $error, string $localeCode): ValidationIssue + { + $validationIssue = new ValidationIssue(); + $validationIssue->setMessage($error->getId()); + $validationIssue->setMessage($error->getMessage()); + $validationIssue->setType(ValidationIssue::TYPE__BUSINESS_RULE); + $validationIssue->setCode(ValidationIssue::CODE__BUSINESS_RULE_ERROR); + + /** @deprecated tag:v11.0.0 - "getTranslatedMessage" is added with v6.7.3.0 */ + // @phpstan-ignore function.alreadyNarrowedType + if (\method_exists($error, 'getTranslatedMessage')) { + $validationIssue->setUserMessage($error->getTranslatedMessage()); + } else { + $parameters = []; + foreach ($error->getParameters() as $key => $value) { + $parameters['%' . $key . '%'] = $value; + } + + $message = $this->translator->trans( + 'checkout.' . $error->getMessageKey(), + $parameters, + null, + $localeCode + ); + + $validationIssue->setUserMessage($message); + } + + switch ($error::class) { + case ProductNotFoundError::class: + case PurchaseStepsError::class: + case MinOrderQuantityError::class: + case ProductOutOfStockError::class: + case ProductStockReachedError::class: + $validationIssue->setCode(ValidationIssue::CODE__INVENTORY_ISSUE); + $validationIssue->setItemId(str_replace($error->getMessageKey(), '', $error->getId())); + + break; + case ShippingAddressBlockedError::class: + case BillingAddressBlockedError::class: + case AddressValidationError::class: + $validationIssue->setCode(ValidationIssue::CODE__SHIPPING_ERROR); + } + + return $validationIssue; + } +} diff --git a/src/Checkout/Cart/Service/ExcludedProductValidator.php b/src/Checkout/Cart/Service/ExcludedProductValidator.php index 859f99623..06e3b15de 100644 --- a/src/Checkout/Cart/Service/ExcludedProductValidator.php +++ b/src/Checkout/Cart/Service/ExcludedProductValidator.php @@ -119,7 +119,7 @@ public function findExcludedProducts(array $productIds, SalesChannelContext $sal $criteria = new Criteria($productIds); $criteria->addAssociation('streams'); $criteria->addFilter(new EqualsAnyFilter('streams.id', $excludedProductStreamIds)); - /** @var string[] $excludedByProductStream */ + /** @var list $excludedByProductStream */ $excludedByProductStream = $this->productRepository->searchIds($criteria, $salesChannelContext)->getIds(); return \array_unique(\array_merge($excludedByProductIds, $excludedByProductStream)); diff --git a/src/Checkout/SalesChannel/MethodEligibilityRoute.php b/src/Checkout/SalesChannel/MethodEligibilityRoute.php index 04945dcb2..b13afae06 100644 --- a/src/Checkout/SalesChannel/MethodEligibilityRoute.php +++ b/src/Checkout/SalesChannel/MethodEligibilityRoute.php @@ -73,7 +73,7 @@ public function getDecorated(): AbstractMethodEligibilityRoute #[Route(path: '/store-api/paypal/payment-method-eligibility', name: 'store-api.paypal.payment-method-eligibility', defaults: ['XmlHttpRequest' => true], methods: ['POST'])] public function setPaymentMethodEligibility(Request $request, Context $context): Response { - /** @var mixed|array $paymentMethods */ + /** @var array $paymentMethods */ $paymentMethods = $request->request->all()['paymentMethods'] ?? null; if (!\is_array($paymentMethods)) { RoutingException::invalidRequestParameter('paymentMethods'); diff --git a/src/DevOps/Command/GenerateOpenApi.php b/src/DevOps/Command/GenerateOpenApi.php index 82aa79df3..d190ad1f4 100644 --- a/src/DevOps/Command/GenerateOpenApi.php +++ b/src/DevOps/Command/GenerateOpenApi.php @@ -75,6 +75,10 @@ protected function generateStoreApiSchema(SymfonyStyle $style, Generator $genera $openApi = $generator->generate([ Util::finder(self::ROOT_DIR . '/src/RestApi'), Util::finder(self::ROOT_DIR . '/src/Checkout'), + Util::finder( + self::ROOT_DIR . '/../../../vendor/shopware/paypal-sdk/src/Struct', + [self::ROOT_DIR . '/../../../vendor/shopware/paypal-sdk/src/Struct/AgenticCommerce'], + ), ])?->toJson(); if ($openApi === null) { @@ -106,6 +110,12 @@ protected function generateAdminApiSchema(SymfonyStyle $style, Generator $genera Util::finder(self::ROOT_DIR . '/src/Pos'), Util::finder(self::ROOT_DIR . '/src/Setting'), Util::finder(self::ROOT_DIR . '/src/Webhook'), + Util::finder(self::ROOT_DIR . '/src/AgentCommerce'), + Util::finder( + self::ROOT_DIR . '/../../../vendor/shopware/paypal-sdk/src/Struct', + [self::ROOT_DIR . '/../../../vendor/shopware/paypal-sdk/src/Struct/AgenticCommerce'], + ), + Util::finder(__DIR__ . '/Polyfill'), ])?->toJson(); if ($openApi === null) { diff --git a/src/Installment/Banner/Service/BannerDataService.php b/src/Installment/Banner/Service/BannerDataService.php index 3027c9161..688b20145 100644 --- a/src/Installment/Banner/Service/BannerDataService.php +++ b/src/Installment/Banner/Service/BannerDataService.php @@ -9,7 +9,6 @@ use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository; use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria; -use Shopware\Core\Framework\DataAbstractionLayer\Search\EntitySearchResult; use Shopware\Core\Framework\Log\Package; use Shopware\Core\System\Language\LanguageCollection; use Shopware\Core\System\Language\LanguageEntity; @@ -34,6 +33,8 @@ class BannerDataService extends AbstractScriptDataService implements BannerDataS { /** * @internal + * + * @param EntityRepository $languageRepository */ public function __construct( LocaleCodeProvider $localeCodeProvider, @@ -97,7 +98,6 @@ public function getInstallmentBannerData( private function determineBuyerCountry(SalesChannelContext $salesChannelContext): ?string { - /** @var EntitySearchResult $languages */ $languages = $this->languageRepository->search( (new Criteria($salesChannelContext->getLanguageIdChain()))->addAssociation('locale'), $salesChannelContext->getContext() diff --git a/src/Migration/Migration1752337399AddAgentCommerceSalesChannelType.php b/src/Migration/Migration1752337399AddAgentCommerceSalesChannelType.php new file mode 100644 index 000000000..fb848bd6d --- /dev/null +++ b/src/Migration/Migration1752337399AddAgentCommerceSalesChannelType.php @@ -0,0 +1,86 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\Migration; + +use Doctrine\DBAL\Connection; +use Shopware\Core\Defaults; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Migration\MigrationStep; +use Shopware\Core\Framework\Uuid\Uuid; +use Shopware\Core\Migration\Traits\ImportTranslationsTrait; +use Shopware\Core\Migration\Traits\Translations; +use Swag\PayPal\SwagPayPal; + +/** + * @internal + */ +#[Package('checkout')] +class Migration1752337399AddAgentCommerceSalesChannelType extends MigrationStep +{ + use ImportTranslationsTrait; + + public function getCreationTimestamp(): int + { + return 1752337399; + } + + public function update(Connection $connection): void + { + $this->createSalesChannelType($connection); + $this->createSalesChannelTypeTranslations($connection); + } + + private function createSalesChannelType(Connection $connection): void + { + $type = $connection->fetchOne( + 'SELECT `id` FROM `sales_channel_type` WHERE `id` = :id', + ['id' => Uuid::fromHexToBytes(SwagPayPal::SALES_CHANNEL_TYPE_AGENT_COMMERCE)] + ); + + if ($type) { + return; + } + + $connection->executeStatement( + 'INSERT INTO `sales_channel_type` (`id`, `cover_url`, `icon_name`, `screenshot_urls`, `created_at`) VALUES (:id, :coverUrl, :iconName, :screenshotUrls, :createdAt)', + [ + 'id' => Uuid::fromHexToBytes(SwagPayPal::SALES_CHANNEL_TYPE_AGENT_COMMERCE), + 'coverUrl' => null, + 'iconName' => 'regular-artificial-intelligence', + 'screenshotUrls' => null, + 'createdAt' => (new \DateTime())->format(Defaults::STORAGE_DATE_TIME_FORMAT), + ] + ); + } + + private function createSalesChannelTypeTranslations(Connection $connection): void + { + $translations = new Translations( + [ + 'sales_channel_type_id' => Uuid::fromHexToBytes(SwagPayPal::SALES_CHANNEL_TYPE_AGENT_COMMERCE), + 'name' => 'Agent Commerce', + 'manufacturer' => 'shopware AG', + 'description' => 'PayPal Agent Commerce Sales Channel', + 'description_long' => 'Der PayPal Agent Commerce ist eine KI Lösung, die es Kunden ermöglicht, Produkte im Chat mit einem KI Agenten zu kaufen. Ordne Produkte zu, die der Agent verkaufen soll, um das Einkaufserlebnis zu verbessern.', + ], + [ + 'sales_channel_type_id' => Uuid::fromHexToBytes(SwagPayPal::SALES_CHANNEL_TYPE_AGENT_COMMERCE), + 'name' => 'Agent Commerce', + 'manufacturer' => 'PayPal', + 'description' => 'PayPal Agent Commerce Sales Channel', + 'description_long' => 'The PayPal Agent Commerce is an AI solution that allows customers to purchase products in a chat with an AI agent. Assign products that the agent should sell to enhance the shopping experience.', + ] + ); + + $this->importTranslation( + 'sales_channel_type_translation', + $translations, + $connection + ); + } +} diff --git a/src/Pos/Api/Common/PosStruct.php b/src/Pos/Api/Common/PosStruct.php index 1b5603cae..b170e6302 100644 --- a/src/Pos/Api/Common/PosStruct.php +++ b/src/Pos/Api/Common/PosStruct.php @@ -34,6 +34,7 @@ public function assign(array $arrayData) if ($this->isScalar($value)) { if ($value !== null) { + // @phpstan-ignore method.dynamicName $this->{$setterMethod}($value); } @@ -49,6 +50,8 @@ public function assign(array $arrayData) } $instance = $this->createNewAssociation($className, $value); + + // @phpstan-ignore method.dynamicName $this->{$setterMethod}($instance); continue; @@ -64,6 +67,8 @@ static function ($var) { return $var !== null; } ); + + // @phpstan-ignore method.dynamicName $this->{$setterMethod}($arrayData); continue; @@ -78,6 +83,8 @@ static function ($var) { $instance = $this->createNewAssociation($className, $toManyAssociation); $arrayWithToManyAssociations[] = $instance; } + + // @phpstan-ignore method.dynamicName $this->{$setterMethod}($arrayWithToManyAssociations); } @@ -93,6 +100,7 @@ public function jsonSerialize(): array foreach (\array_keys(\get_class_vars(static::class)) as $property) { try { + // @phpstan-ignore property.dynamicName $data[$property] = $this->{$property}; /* @phpstan-ignore-next-line */ } catch (\Error $error) { diff --git a/src/Pos/MessageQueue/Handler/Sync/ProductCleanupSyncHandler.php b/src/Pos/MessageQueue/Handler/Sync/ProductCleanupSyncHandler.php index 0af859779..aef38ec00 100644 --- a/src/Pos/MessageQueue/Handler/Sync/ProductCleanupSyncHandler.php +++ b/src/Pos/MessageQueue/Handler/Sync/ProductCleanupSyncHandler.php @@ -69,7 +69,7 @@ public function sync(AbstractSyncMessage $message): void $salesChannelContext = $message->getSalesChannelContext(); - /** @var string[] $productIds */ + /** @var list $productIds */ $productIds = $this->productRepository->searchIds($criteria, $salesChannelContext)->getIds(); $this->productSyncer->cleanUp($productIds, $message->getSalesChannel(), $message->getContext()); diff --git a/src/Pos/Setting/Service/ProductVisibilityCloneService.php b/src/Pos/Setting/Service/ProductVisibilityCloneService.php index 61320e798..5957f6801 100644 --- a/src/Pos/Setting/Service/ProductVisibilityCloneService.php +++ b/src/Pos/Setting/Service/ProductVisibilityCloneService.php @@ -59,7 +59,7 @@ public function cloneProductVisibility( $deletionCriteria = new Criteria(); $deletionCriteria->addFilter(new EqualsFilter('salesChannelId', $toSalesChannelId)); - /** @var string[] $formerVisibilityIds */ + /** @var list $formerVisibilityIds */ $formerVisibilityIds = $this->productVisibilityRepository->searchIds($deletionCriteria, $context)->getIds(); if (\count($formerVisibilityIds) > 0) { $formerVisibilityIds = \array_map(static function (string $id) { diff --git a/src/Resources/Schema/AdminApi/openapi.json b/src/Resources/Schema/AdminApi/openapi.json index 643f02295..6291527b5 100644 --- a/src/Resources/Schema/AdminApi/openapi.json +++ b/src/Resources/Schema/AdminApi/openapi.json @@ -1651,6 +1651,46 @@ } } } + }, + "/_action/paypal/honey/webhook/register/{salesChannelId}": { + "post": { + "tags": [ + "Admin Api", + "SwagPayPalWebhook" + ], + "operationId": "registerHoneyWebhook", + "parameters": [ + { + "parameter": "salesChannelId", + "name": "salesChannelId", + "in": "path", + "schema": { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + } + } + ], + "responses": { + "200": { + "description": "Returns the action taken for the webhook registration", + "content": { + "application/json": { + "schema": { + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + } + }, + "type": "object" + } + } + } + } + } + } } }, "components": { diff --git a/src/Resources/app/administration/src/constant/swag-paypal.constant.ts b/src/Resources/app/administration/src/constant/swag-paypal.constant.ts index cd81dd1a0..907892f90 100644 --- a/src/Resources/app/administration/src/constant/swag-paypal.constant.ts +++ b/src/Resources/app/administration/src/constant/swag-paypal.constant.ts @@ -1,7 +1,10 @@ export const PAYPAL_POS_SALES_CHANNEL_TYPE_ID = '1ce0868f406d47d98cfe4b281e62f099'; export const PAYPAL_POS_SALES_CHANNEL_EXTENSION = 'paypalPosSalesChannel'; +export const PAYPAL_AGENT_COMMERCE_SALES_CHANNEL_TYPE_ID = 'e3f8c9b2f1a44d4db0f793542e31d2c9'; + export default { PAYPAL_POS_SALES_CHANNEL_TYPE_ID, PAYPAL_POS_SALES_CHANNEL_EXTENSION, + PAYPAL_AGENT_COMMERCE_SALES_CHANNEL_TYPE_ID, }; diff --git a/src/Resources/app/administration/src/core/service/api/swag-paypal-honey-webhook.service.ts b/src/Resources/app/administration/src/core/service/api/swag-paypal-honey-webhook.service.ts new file mode 100644 index 000000000..85d635a98 --- /dev/null +++ b/src/Resources/app/administration/src/core/service/api/swag-paypal-honey-webhook.service.ts @@ -0,0 +1,19 @@ +import type { LoginService } from 'src/core/service/login.service'; +import type { AxiosInstance } from 'axios'; +import type * as PayPal from 'SwagPayPal/types'; + +const ApiService = Shopware.Classes.ApiService; + +export default class SwagPayPalHoneyWebhookService extends ApiService { + constructor(httpClient: AxiosInstance, loginService: LoginService, apiEndpoint = 'paypal') { + super(httpClient, loginService, apiEndpoint); + } + + register(salesChannelId: string | null) { + return this.httpClient.post>( + `_action/${this.getApiBasePath()}/honey/webhook/register/${salesChannelId}`, + {}, + { headers: this.getBasicHeaders() }, + ).then(ApiService.handleResponse.bind(this)); + } +} diff --git a/src/Resources/app/administration/src/global.types.ts b/src/Resources/app/administration/src/global.types.ts index 4ef197f94..e7ef5df3a 100644 --- a/src/Resources/app/administration/src/global.types.ts +++ b/src/Resources/app/administration/src/global.types.ts @@ -10,6 +10,7 @@ import type SwagPaypalSettingsMixin from './mixin/swag-paypal-settings.mixin'; import type SwagPaypalMerchantInformationMixin from './mixin/swag-paypal-merchant-information.mixin'; import type SwagPayPalApiCredentialsService from './core/service/api/swag-paypal-api-credentials.service'; import type SwagPayPalDisputeApiService from './core/service/api/swag-paypal-dispute.api.service'; +import type SwagPayPalHoneyWebhookService from './core/service/api/swag-paypal-honey-webhook.service'; import type SwagPayPalOrderService from './core/service/api/swag-paypal-order.service'; import type SwagPaypalPaymentMethodService from './core/service/api/swag-paypal-payment-method.service'; import type SwagPayPalPaymentService from './core/service/api/swag-paypal-payment.service'; @@ -45,6 +46,7 @@ declare global { SwagPayPalPosWebhookRegisterService: SwagPayPalPosWebhookRegisterService; SwagPayPalWebhookService: SwagPayPalWebhookService; SwagPayPalPaymentService: SwagPayPalPaymentService; + SwagPayPalHoneyWebhookService: SwagPayPalHoneyWebhookService; SwagPayPalOrderService: SwagPayPalOrderService; SwagPaypalPaymentMethodService: SwagPaypalPaymentMethodService; SwagPayPalDisputeApiService: SwagPayPalDisputeApiService; diff --git a/src/Resources/app/administration/src/init/api-service.init.ts b/src/Resources/app/administration/src/init/api-service.init.ts index 59bcfdcc6..8200cf4b5 100644 --- a/src/Resources/app/administration/src/init/api-service.init.ts +++ b/src/Resources/app/administration/src/init/api-service.init.ts @@ -8,6 +8,7 @@ import SwagPayPalOrderService from '../core/service/api/swag-paypal-order.servic import SwagPaypalPaymentMethodService from '../core/service/api/swag-paypal-payment-method.service'; import SwagPayPalDisputeApiService from '../core/service/api/swag-paypal-dispute.api.service'; import SwagPayPalSettingsService from '../core/service/api/swag-paypal-settings.service'; +import SwagPayPalHoneyWebhookService from "SwagPayPal/core/service/api/swag-paypal-honey-webhook.service"; const { Application } = Shopware; @@ -43,6 +44,11 @@ Application.addServiceProvider( (container) => new SwagPayPalPaymentService(initContainer.httpClient, container.loginService), ); +Application.addServiceProvider( + 'SwagPayPalHoneyWebhookService', + (container) => new SwagPayPalHoneyWebhookService(initContainer.httpClient, container.loginService), +); + Application.addServiceProvider( 'SwagPayPalOrderService', (container) => new SwagPayPalOrderService(initContainer.httpClient, container.loginService), diff --git a/src/Resources/app/administration/src/main.ts b/src/Resources/app/administration/src/main.ts index 94146d844..e1d2a2b5a 100644 --- a/src/Resources/app/administration/src/main.ts +++ b/src/Resources/app/administration/src/main.ts @@ -8,6 +8,7 @@ import './mixin/swag-paypal-settings.mixin'; import './mixin/swag-paypal-merchant-information.mixin'; import './module/extension'; +import './module/swag-paypal-agent-commerce'; import './module/swag-paypal-disputes'; import './module/swag-paypal-payment'; import './module/swag-paypal-pos'; diff --git a/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/extension/sw-sales-channel-create/index.ts b/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/extension/sw-sales-channel-create/index.ts new file mode 100644 index 000000000..f9d50c665 --- /dev/null +++ b/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/extension/sw-sales-channel-create/index.ts @@ -0,0 +1,28 @@ +import exportHeader from 'SwagPayPal/module/swag-paypal-agent-commerce/static/product-export/header.txt?raw'; +import exportBody from 'SwagPayPal/module/swag-paypal-agent-commerce/static/product-export/body.txt?raw'; + +export default Shopware.Component.wrapComponentConfig<{ salesChannel: TEntity<'sales_channel'>; productExport: TEntity<'product_export'> }>({ + methods: { + /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call*/ + async createdComponent() { + // create dummy sales channel to avoid race condition in base components + // @ts-expect-error - salesChannelRepository is defined in the parent component + this.salesChannel = this.salesChannelRepository.create(); + this.salesChannel.typeId = this.$route.params.typeId as string; + + await this.$super('createdComponent'); + + this.productExport.name = this.$tc('swag-paypal-agent-commerce.product-export.name'); + this.productExport.translationKey = 'swag-paypal-agent-commerce.sw-sales-channel.productComparison.templates.template-label.commerce-agent'; + this.productExport.footerTemplate = ''; + this.productExport.fileName = `paypal-agent-commerce-export-${this.salesChannel.id}.csv`; + this.productExport.encoding = 'UTF-8'; + this.productExport.fileFormat = 'csv'; + this.productExport.generateByCronjob = false; + this.productExport.interval = 86400; + + this.productExport.headerTemplate = exportHeader.replace(/[\r\n]+/g, ''); + this.productExport.bodyTemplate = exportBody.replace(/[\r\n]+/g, ''); + }, + }, +}); diff --git a/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/extension/sw-sales-channel-detail-base/index.ts b/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/extension/sw-sales-channel-detail-base/index.ts new file mode 100644 index 000000000..ec950887d --- /dev/null +++ b/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/extension/sw-sales-channel-detail-base/index.ts @@ -0,0 +1,73 @@ +import template from './sw-sales-channel-detail-base.html.twig'; +import { PAYPAL_AGENT_COMMERCE_SALES_CHANNEL_TYPE_ID } from "SwagPayPal/constant/swag-paypal.constant"; +import './sw-sales-channel-detail-base.scss'; +import type * as PayPal from "SwagPayPal/types"; + +export default Shopware.Component.wrapComponentConfig({ + template, + + inject: [ + 'systemConfigApiService', + 'SwagPayPalHoneyWebhookService', + ], + + data(): { + webhookRegistered: boolean; + isRefreshingWebhook: boolean; + } { + return { + webhookRegistered: false, + isRefreshingWebhook: false, + }; + }, + + computed: { + isAgentCommerceType(): boolean { + // @ts-expect-error - salesChannel is defined in the parent component + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + return this.salesChannel?.typeId === PAYPAL_AGENT_COMMERCE_SALES_CHANNEL_TYPE_ID; + }, + + isProductComparison(): boolean { + return this.isAgentCommerceType || this.$super('isProductComparison') as boolean; + }, + + webhookStatusLabel() { + return this.$t(`swag-paypal-settings.webhook.status.${this.webhookRegistered ? 'valid' : 'missing'}`); + }, + + webhookStatusVariant(): 'danger' | 'success' { + return this.webhookRegistered ? 'success' : 'danger'; + }, + }, + + created() { + this.createdComponent(); + }, + + methods: { + createdComponent() { + // @ts-expect-error - salesChannel is defined in the parent component + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const salesChannelId = this.salesChannel.id as string; + + this.systemConfigApiService + .getValues('SwagPayPal.settings', salesChannelId) + .then((values: PayPal.SystemConfig) => { + this.webhookRegistered = !!(values['SwagPayPal.settings.agentCommerceOnboarded'] ?? false); + }); + }, + + onRefreshWebhook() { + this.isRefreshingWebhook = true; + + // @ts-expect-error - salesChannel is defined in the parent component + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + this.SwagPayPalHoneyWebhookService.register(this.salesChannel.id as string).then(() => { + this.createdComponent(); + + this.isRefreshingWebhook = false; + }); + }, + }, +}); diff --git a/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/extension/sw-sales-channel-detail-base/sw-sales-channel-detail-base.html.twig b/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/extension/sw-sales-channel-detail-base/sw-sales-channel-detail-base.html.twig new file mode 100644 index 000000000..7a34d49e1 --- /dev/null +++ b/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/extension/sw-sales-channel-detail-base/sw-sales-channel-detail-base.html.twig @@ -0,0 +1,105 @@ +{% block sw_sales_channel_detail_base_general_input_product_comparison_template %} + +{% endblock %} + +{% block sw_sales_channel_detail_base_general_input_product_comparison_filename %} + +{% endblock %} + +{% block sw_sales_channel_detail_base_general_input_product_comparison_encoding %} + +{% endblock %} + +{% block sw_sales_channel_detail_base_general_input_product_comparison_file_format %} + +{% endblock %} + +{% block sw_sales_channel_detail_base_options_status_description %} + +{% endblock %} + +{% block sw_sales_channel_detail_base_options_api %} + +{% endblock %} + +{% block sw_sales_channel_detail_base_options_maintenance_header %} + +{% endblock %} + +{% block sw_sales_channel_detail_base_maintenance_input_active %} + +{% endblock %} + +{% block sw_sales_channel_detail_base_maintenance_ipwhitelist %} + +{% endblock %} + +{% block sw_sales_channel_detail_base_settings_link %} + +{% endblock %} + +{% block sw_sales_channel_detail_base_options_delete %} + + + +
+ + {{ $t('swag-paypal-settings.webhook.honey.info') }} + + + + {{ $t('swag-paypal-settings.webhook.buttonRefresh') }} + +
+
+ + {% parent %} +{% endblock %} diff --git a/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/extension/sw-sales-channel-detail-base/sw-sales-channel-detail-base.scss b/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/extension/sw-sales-channel-detail-base/sw-sales-channel-detail-base.scss new file mode 100644 index 000000000..4a12276e0 --- /dev/null +++ b/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/extension/sw-sales-channel-detail-base/sw-sales-channel-detail-base.scss @@ -0,0 +1,36 @@ +@import "~scss/variables"; + +.swag-paypal-settings-webhook { + &__title { + display: grid; + grid-template-columns: min-content min-content; + gap: 12px; + place-items: center; + + p { + font-weight: var(--font-weight-semibold); + font-size: var(--font-size-m); + } + } + + &__content { + display: grid; + gap: 16px; + place-items: end; + font-size: var(--font-size-xs); + line-height: var(--line-height-sm); + } + + & &__label { + border: 0; + margin: 0; + + .sw-label__caption { + overflow: visible; + } + + .sw-color-badge { + margin: 0 0 0 2px; + } + } +} diff --git a/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/extension/sw-sales-channel-detail-base/sw-sales-channel-detail-base.spec.ts b/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/extension/sw-sales-channel-detail-base/sw-sales-channel-detail-base.spec.ts new file mode 100644 index 000000000..0f6518401 --- /dev/null +++ b/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/extension/sw-sales-channel-detail-base/sw-sales-channel-detail-base.spec.ts @@ -0,0 +1,105 @@ +import { mount } from '@vue/test-utils'; +import SwSalesChannelDetailBase from 'src/module/sw-sales-channel/view/sw-sales-channel-detail-base'; +import SwSalesChannelDetailBaseExtension from '.'; +import { PAYPAL_AGENT_COMMERCE_SALES_CHANNEL_TYPE_ID } from "SwagPayPal/constant/swag-paypal.constant"; + +Shopware.Component.register('sw-sales-channel-detail-base', Promise.resolve({ + ...SwSalesChannelDetailBase, + template: '
stub
', +})); + +Shopware.Component.extend('swag-paypal-agent-commerce-sales-channel-detail-base', 'sw-sales-channel-detail-base', Promise.resolve(SwSalesChannelDetailBaseExtension)); + +async function createWrapper() { + return mount(await Shopware.Component.build('swag-paypal-agent-commerce-sales-channel-detail-base') as typeof SwSalesChannelDetailBaseExtension, { + props: { + salesChannel: {}, + productExport: {}, + customFieldSets: [], + }, + global: { + provide: { + salesChannelService: { + generateKey: () => { return new Promise((resolve) => resolve('generated-key')); }, + }, + productExportService: {}, + knownIpsService: { + getKnownIps: () => Promise.resolve(), + }, + repositoryFactory: { + create: () => ({ + search: () => { + return Promise.resolve([]); + }, + get: () => { + return Promise.resolve(); + }, + delete: () => { + return Promise.resolve(); + }, + }), + }, + systemConfigApiService: { getValues: () => Promise.resolve([]) }, + SwagPayPalHoneyWebhookService: { + register: jest.fn(() => Promise.resolve()), + }, + }, + }, + }); +} + +describe('sw-sales-channel-detail-base', () => { + it('should be a Vue component', async () => { + const wrapper = await createWrapper(); + + expect(wrapper.vm).toBeTruthy(); + }); + + it('should be agent commerce type', async () => { + const wrapper = await createWrapper(); + + await wrapper.setProps({ + salesChannel: { + typeId: PAYPAL_AGENT_COMMERCE_SALES_CHANNEL_TYPE_ID, + }, + }); + + expect(wrapper.vm.isAgentCommerceType).toBeTruthy(); + }); + + it('should not be agent commerce type', async () => { + const wrapper = await createWrapper(); + + await wrapper.setProps({ + salesChannel: { + typeId: 'some-other-type-id', + }, + }); + + expect(wrapper.vm.isAgentCommerceType).toBeFalsy(); + }); + + it('should be product comparison', async () => { + const wrapper = await createWrapper(); + + await wrapper.setProps({ + salesChannel: { + typeId: Shopware.Defaults.productComparisonTypeId, + }, + }); + + expect(wrapper.vm.isProductComparison).toBeTruthy(); + }); + + it('should not be product comparison', async () => { + const wrapper = await createWrapper(); + + await wrapper.setProps({ + salesChannel: { + typeId: 'some-other-type-id', + }, + }); + + expect(wrapper.vm.isProductComparison).toBeFalsy(); + }); +}); diff --git a/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/extension/sw-sales-channel-detail/index.ts b/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/extension/sw-sales-channel-detail/index.ts new file mode 100644 index 000000000..46d3c1d94 --- /dev/null +++ b/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/extension/sw-sales-channel-detail/index.ts @@ -0,0 +1,18 @@ +import template from './sw-sales-channel-detail.html.twig'; +import { PAYPAL_AGENT_COMMERCE_SALES_CHANNEL_TYPE_ID } from "SwagPayPal/constant/swag-paypal.constant"; + +export default Shopware.Component.wrapComponentConfig({ + template, + + computed: { + isAgentCommerceType(): boolean { + // @ts-expect-error - salesChannel is defined in the parent component + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + return this.salesChannel?.typeId === PAYPAL_AGENT_COMMERCE_SALES_CHANNEL_TYPE_ID; + }, + + isProductComparison(): boolean { + return this.isAgentCommerceType || this.$super('isProductComparison') as boolean; + }, + }, +}); diff --git a/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/extension/sw-sales-channel-detail/sw-sales-channel-detail.html.twig b/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/extension/sw-sales-channel-detail/sw-sales-channel-detail.html.twig new file mode 100644 index 000000000..42a5de3a2 --- /dev/null +++ b/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/extension/sw-sales-channel-detail/sw-sales-channel-detail.html.twig @@ -0,0 +1,6 @@ +{% block sw_sales_channel_detail_content_tabs %} + +{% endblock %} + diff --git a/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/extension/sw-sales-channel-detail/sw-sales-channel-detail.spec.ts b/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/extension/sw-sales-channel-detail/sw-sales-channel-detail.spec.ts new file mode 100644 index 000000000..5a828638d --- /dev/null +++ b/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/extension/sw-sales-channel-detail/sw-sales-channel-detail.spec.ts @@ -0,0 +1,79 @@ +import { mount } from '@vue/test-utils'; +import SwSalesChannelDetail from 'src/module/sw-sales-channel/page/sw-sales-channel-detail'; +import SwSalesChannelDetailExtension from '.'; +import { PAYPAL_AGENT_COMMERCE_SALES_CHANNEL_TYPE_ID } from "SwagPayPal/constant/swag-paypal.constant"; + +Shopware.Component.register('sw-sales-channel-detail', Promise.resolve({ + ...SwSalesChannelDetail, + template: '
stub
', +})); + +Shopware.Component.extend('swag-paypal-agent-commerce-sales-channel-detail', 'sw-sales-channel-detail', Promise.resolve(SwSalesChannelDetailExtension)); + +async function createWrapper() { + return mount(await Shopware.Component.build('swag-paypal-agent-commerce-sales-channel-detail') as typeof SwSalesChannelDetailExtension, { + global: { + provide: { + exportTemplateService: { + getProductExportTemplateRegistry: () => { return {}; }, + }, + }, + }, + }); +} + +describe('sw-sales-channel-detail', () => { + it('should be a Vue component', async () => { + const wrapper = await createWrapper(); + + expect(wrapper.vm).toBeTruthy(); + }); + + it('should be agent commerce type', async () => { + const wrapper = await createWrapper(); + + wrapper.setData({ + salesChannel: { + typeId: PAYPAL_AGENT_COMMERCE_SALES_CHANNEL_TYPE_ID, + }, + }); + + expect(wrapper.vm.isAgentCommerceType).toBeTruthy(); + }); + + it('should not be agent commerce type', async () => { + const wrapper = await createWrapper(); + + wrapper.setData({ + salesChannel: { + typeId: 'some-other-type-id', + }, + }); + + expect(wrapper.vm.isAgentCommerceType).toBeFalsy(); + }); + + it('should be product comparison', async () => { + const wrapper = await createWrapper(); + + wrapper.setData({ + salesChannel: { + typeId: Shopware.Defaults.productComparisonTypeId, + }, + }); + + expect(wrapper.vm.isProductComparison).toBeTruthy(); + }); + + it('should not be product comparison', async () => { + const wrapper = await createWrapper(); + + wrapper.setData({ + salesChannel: { + typeId: 'some-other-type-id', + }, + }); + + expect(wrapper.vm.isProductComparison).toBeFalsy(); + }); +}); diff --git a/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/index.ts b/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/index.ts new file mode 100644 index 000000000..0ecbdff57 --- /dev/null +++ b/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/index.ts @@ -0,0 +1,5 @@ +Shopware.Component.override('sw-sales-channel-create', import('./extension/sw-sales-channel-create')); + +Shopware.Component.override('sw-sales-channel-detail', import('./extension/sw-sales-channel-detail')); + +Shopware.Component.override('sw-sales-channel-detail-base', import('./extension/sw-sales-channel-detail-base')); diff --git a/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/snippet/de-DE.json b/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/snippet/de-DE.json new file mode 100644 index 000000000..3cc0ba4b4 --- /dev/null +++ b/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/snippet/de-DE.json @@ -0,0 +1,12 @@ +{ + "swag-paypal-agent-commerce": { + "sales-channel": { + "detail": { + "textActiveDescription": "Deaktiviert die PayPal Agent Commerce Integration für diesen Verkaufskanal" + } + }, + "product-export": { + "name": "PayPal Agent Commerce Export" + } + } +} diff --git a/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/snippet/en-GB.json b/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/snippet/en-GB.json new file mode 100644 index 000000000..de400eec7 --- /dev/null +++ b/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/snippet/en-GB.json @@ -0,0 +1,12 @@ +{ + "swag-paypal-agent-commerce": { + "sales-channel": { + "detail": { + "textActiveDescription": "Deactivates the PayPal Agent Commerce Integration for this Sales Channel" + } + }, + "product-export": { + "name": "PayPal Agent Commerce Export" + } + } +} diff --git a/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/static/product-export/body.txt b/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/static/product-export/body.txt new file mode 100644 index 000000000..bdf511cd4 --- /dev/null +++ b/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/static/product-export/body.txt @@ -0,0 +1,27 @@ +{%- set preOrder = product.releaseDate and product.releaseDate|date('U') > 'now'|date('U') -%} +{%- set price = product.calculatedPrice -%} +{%- if product.calculatedPrices.count > 0 -%} + {%- set price = product.calculatedPrices.last -%} +{%- endif -%} +{%- set priceValue = price.unitPrice -%} + +"{{ product.id }}", +"{{ product.parentId }}", +"{{ product.translated.name }}", +"{{ seoUrl('frontend.detail.page', {'productId': product.id}) }}", +"{{ product.manufacturerNumber }}", +"{{ product.manufacturer.translated.name|default('') }}", +"{{ product.ean }}", +"{{ priceValue|number_format(context.currency.itemRounding.decimals, '.', '') }} {{ context.currency.isoCode }}", +"{{ priceValue|number_format(context.currency.itemRounding.decimals, '.', '') }} {{ context.currency.isoCode }}", +"{%- if preOrder -%}preorder{%- elseif product.availableStock >= product.minPurchase -%}in_stock{%- else -%}out_of_stock{%- endif -%}", +"{%- if preOrder -%}{{ product.releaseDate|date(constant('\DateTimeInterface::ISO8601'), product.releaseDate.timezone) }}{%- endif -%}", +"{%- if product.categories.count > 0 -%} + {{ product.categories.first.getBreadCrumb|slice(1)|join(' > ')|raw }} +{%- endif -%}", +"{%- if product.categories.count > 0 -%} + {{ product.categories.first.getBreadCrumb|slice(1)|join(' > ')|raw }} +{%- endif -%}", +"{% if product.cover is not null %}{{ product.cover.media.url }}{% endif %}", +"{% if product.media.first is not null %}{{ product.media.first.media.url }}{% endif %}", +"{{ product.translated.description|striptags|raw|length > 300 ? product.translated.description|striptags|raw|slice(0,297) ~ '...' : product.translated.description|striptags|raw }}" diff --git a/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/static/product-export/header.txt b/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/static/product-export/header.txt new file mode 100644 index 000000000..789066371 --- /dev/null +++ b/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/static/product-export/header.txt @@ -0,0 +1,16 @@ +"id", +"item_group_id", +"title", +"link", +"mpn", +"brand", +"gtin", +"sale_price", +"price", +"availability", +"availability_date", +"google_product_category", +"product_type", +"image_link", +"additional_image_link", +"description" diff --git a/src/Resources/app/administration/src/module/swag-paypal-settings/snippet/de-DE.json b/src/Resources/app/administration/src/module/swag-paypal-settings/snippet/de-DE.json index 5a8953968..84d235f3a 100644 --- a/src/Resources/app/administration/src/module/swag-paypal-settings/snippet/de-DE.json +++ b/src/Resources/app/administration/src/module/swag-paypal-settings/snippet/de-DE.json @@ -93,6 +93,9 @@ "missing": "Fehlend", "invalid": "Registriert, aber mit abweichender Domain", "valid": "Gültig" + }, + "honey": { + "info": "Der Webhook wird zur registrierung verwendet. Dadurch werden alle nötigen Daten, die zur aktivierung und nutzung des Verkaufskanals benötigt werden übermittelt." } }, "crossBorder": { diff --git a/src/Resources/app/administration/src/module/swag-paypal-settings/snippet/en-GB.json b/src/Resources/app/administration/src/module/swag-paypal-settings/snippet/en-GB.json index 8701f32e1..50e6aed67 100644 --- a/src/Resources/app/administration/src/module/swag-paypal-settings/snippet/en-GB.json +++ b/src/Resources/app/administration/src/module/swag-paypal-settings/snippet/en-GB.json @@ -93,6 +93,9 @@ "missing": "Not registered", "invalid": "Registered, but possible domain mismatch", "valid": "Registered" + }, + "honey": { + "info": "The webhook is used for registration. It transmits all the data required to activate and use the sales channel." } }, "crossBorder": { diff --git a/src/Resources/app/administration/src/types/system-config.ts b/src/Resources/app/administration/src/types/system-config.ts index 962fdaaad..9e6e21e6b 100644 --- a/src/Resources/app/administration/src/types/system-config.ts +++ b/src/Resources/app/administration/src/types/system-config.ts @@ -73,6 +73,8 @@ export declare type SystemConfig = { 'SwagPayPal.settings.crossBorderMessagingEnabled'?: boolean; 'SwagPayPal.settings.crossBorderBuyerCountry'?: typeof COUNTRY_OVERRIDES[number] | null; + + 'SwagPayPal.settings.agentCommerceOnboarded'?: string; }; /** diff --git a/src/Resources/config/routes.xml b/src/Resources/config/routes.xml index bb7dd832b..7575728a5 100644 --- a/src/Resources/config/routes.xml +++ b/src/Resources/config/routes.xml @@ -5,6 +5,8 @@ https://symfony.com/schema/routing/routing-1.0.xsd"> + + diff --git a/src/Resources/config/services/agent_commerce.xml b/src/Resources/config/services/agent_commerce.xml new file mode 100644 index 000000000..7ee6b40c3 --- /dev/null +++ b/src/Resources/config/services/agent_commerce.xml @@ -0,0 +1,140 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + https://d.joinhoney.com/ + 20 + 20 + + text/plain + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Resources/snippet/storefront/paypal.de-DE.json b/src/Resources/snippet/storefront/paypal.de-DE.json index 1fc18aee1..24cd0d557 100644 --- a/src/Resources/snippet/storefront/paypal.de-DE.json +++ b/src/Resources/snippet/storefront/paypal.de-DE.json @@ -64,7 +64,7 @@ }, "smartPaymentButtons": { "confirmPageHint": "Ihre Zahlung wurde erstellt. Bitte schließen Sie sie ab, indem Sie Ihre Bestellung bestätigen.", - "description": "Einfach, schnell und sicher bezahlen - Bitte wählen Sie eine dieser Zahlungsmethoden auf der Bestellabschlussseite." + "description": "Einfach, schnell und sicher bezahlen - Bitte wählen Sie eine dieser Zahlungsmethoden auf der Bestellabschlussseite." }, "plus": { "paymentNameOverwrite": "PayPal, Lastschrift oder Kreditkarte", @@ -75,18 +75,32 @@ "googlePay": "Google-Pay-Zahlungen" }, "vault": { - "account": { - "clear": "Konto entfernen", - "create": "Konto für zukünftige Käufe speichern" - }, - "card": { - "clear": "Karte entfernen", - "create": "Karte für zukünftige Käufe speichern" - } + "account": { + "clear": "Konto entfernen", + "create": "Konto für zukünftige Käufe speichern" + }, + "card": { + "clear": "Karte entfernen", + "create": "Karte für zukünftige Käufe speichern" + } }, - "e-invoice":{ - "paymentMethod": "Zahlungsart: \"%paymentMethod%\"", - "orderId": "Auftragsnummer: %orderId%" + "e-invoice": { + "paymentMethod": "Zahlungsart: \"%paymentMethod%\"", + "orderId": "Auftragsnummer: %orderId%" + } + }, + "agentCommerce": { + "validationIssue": { + "userMessage": { + "outOfStock": "{0} Das Produkt \"%name%\" ist nicht mehr auf Lager | [1,Inf[ Das Produkt \"%name%\" ist nur noch %count% mal auf Lager", + "priceChanged": "Produktpreis von \"%name%\" hat sich von %oldPrice% zu %newPrice% geändert" + }, + "resolutionOption": { + "removeLabel": "Aus Warenkorb entfernen", + "acceptLabel": "Fortfahren mit %option%", + "waitRestockLabel": "Informieren, wenn \"%name%\" wieder vorrätig ist", + "estimatedTime": "%days% Tage" + } } } } diff --git a/src/Resources/snippet/storefront/paypal.en-GB.json b/src/Resources/snippet/storefront/paypal.en-GB.json index cbc6a6f18..eb5642f18 100644 --- a/src/Resources/snippet/storefront/paypal.en-GB.json +++ b/src/Resources/snippet/storefront/paypal.en-GB.json @@ -75,18 +75,32 @@ "googlePay": "Google Pay payments" }, "vault": { - "account": { - "clear": "Remove account", - "create": "Remember account for future purchases" - }, - "card": { - "clear": "Remove card", - "create": "Remember card for future purchases" - } + "account": { + "clear": "Remove account", + "create": "Remember account for future purchases" + }, + "card": { + "clear": "Remove card", + "create": "Remember card for future purchases" + } + }, + "e-invoice": { + "paymentMethod": "Payment method: \"%paymentMethod%\"", + "orderId": "Order ID: %orderId%" }, - "e-invoice":{ - "paymentMethod": "Payment method: \"%paymentMethod%\"", - "orderId": "Order ID: %orderId%" + "agentCommerce": { + "validationIssue": { + "userMessage": { + "outOfStock": "{0} The product \"%name%\" is out of stock | {1} The product \"%name%\" is only in stock once | [2,Inf[ The product \"%name%\" has only a stock of %count%", + "priceChanged": "The product \"%name%\" price has changed from %oldPrice% to %newPrice% since cart creation" + }, + "resolutionOption": { + "removeLabel": "Remove from cart", + "acceptLabel": "Continue with %option%", + "waitRestockLabel": "Notify when \"%name%\" is back in stock", + "estimatedTime": "%days% days" + } + } } } } diff --git a/src/Setting/Settings.php b/src/Setting/Settings.php index 450c2cdf3..9a418f0b2 100644 --- a/src/Setting/Settings.php +++ b/src/Setting/Settings.php @@ -75,6 +75,7 @@ final class Settings public const VAULTING_ENABLED_ACDC = self::SYSTEM_CONFIG_DOMAIN . 'vaultingEnabledACDC'; public const VAULTING_ENABLED_VENMO = self::SYSTEM_CONFIG_DOMAIN . 'vaultingEnabledVenmo'; + public const AGENT_COMMERCE_ONBOARDED = self::SYSTEM_CONFIG_DOMAIN . 'agentCommerceOnboarded'; /** * @internal these may change at any time diff --git a/src/SwagPayPal.php b/src/SwagPayPal.php index d8e391406..c98115ff0 100644 --- a/src/SwagPayPal.php +++ b/src/SwagPayPal.php @@ -65,6 +65,7 @@ class SwagPayPal extends Plugin public const SHIPPING_METHOD_CUSTOM_FIELDS_CARRIER = 'swag_paypal_carrier'; public const SHIPPING_METHOD_CUSTOM_FIELDS_CARRIER_OTHER_NAME = 'swag_paypal_carrier_other_name'; public const SALES_CHANNEL_TYPE_POS = '1ce0868f406d47d98cfe4b281e62f099'; + public const SALES_CHANNEL_TYPE_AGENT_COMMERCE = 'e3f8c9b2f1a44d4db0f793542e31d2c9'; public const SALES_CHANNEL_POS_EXTENSION = 'paypalPosSalesChannel'; public const PRODUCT_LOG_POS_EXTENSION = 'paypalPosLog'; public const PRODUCT_SYNC_POS_EXTENSION = 'paypalPosSync'; @@ -109,17 +110,11 @@ public function update(UpdateContext $updateContext): void { \assert($this->container instanceof ContainerInterface, 'Container is not set yet, please call setContainer() before calling boot(), see `platform/Core/Kernel.php:186`.'); - /** @var WebhookService|null $webhookService */ $webhookService = $this->container->get(WebhookService::class, ContainerInterface::NULL_ON_INVALID_REFERENCE); - /** @var InformationDefaultService|null $informationDefaultService */ $informationDefaultService = $this->container->get(InformationDefaultService::class, ContainerInterface::NULL_ON_INVALID_REFERENCE); - /** @var PosWebhookService|null $posWebhookService */ $posWebhookService = $this->container->get(PosWebhookService::class, ContainerInterface::NULL_ON_INVALID_REFERENCE); - /** @var PaymentMethodInstaller|null $paymentMethodInstaller */ $paymentMethodInstaller = $this->container->get(PaymentMethodInstaller::class, ContainerInterface::NULL_ON_INVALID_REFERENCE); - /** @var PaymentMethodStateService|null $paymentMethodStateService */ $paymentMethodStateService = $this->container->get(PaymentMethodStateService::class, ContainerInterface::NULL_ON_INVALID_REFERENCE); - /** @var MediaInstaller|null $mediaInstaller */ $mediaInstaller = $this->container->get(MediaInstaller::class, ContainerInterface::NULL_ON_INVALID_REFERENCE); $paymentMethodDataRegistry = new PaymentMethodDataRegistry( $this->getRepository($this->container, PaymentMethodDefinition::ENTITY_NAME), diff --git a/src/Util/IntrospectionProcessor.php b/src/Util/IntrospectionProcessor.php index 022e40614..fc80b8d78 100644 --- a/src/Util/IntrospectionProcessor.php +++ b/src/Util/IntrospectionProcessor.php @@ -53,7 +53,6 @@ public function __invoke(LogRecord $record): LogRecord return $record; } - /** @var Trace[] $traces */ $traces = $this->getBacktrace(); $extra = []; @@ -127,7 +126,7 @@ public function __invoke(LogRecord $record): LogRecord } /** - * @return Trace + * @return Trace[] */ protected function getBacktrace(): array { diff --git a/src/Util/Lifecycle/State/PaymentMethodStateService.php b/src/Util/Lifecycle/State/PaymentMethodStateService.php index 6698d446c..8c5587fe7 100644 --- a/src/Util/Lifecycle/State/PaymentMethodStateService.php +++ b/src/Util/Lifecycle/State/PaymentMethodStateService.php @@ -80,7 +80,7 @@ public function setAllPaymentMethodsState(bool $active, Context $context): void $criteria = new Criteria(); $criteria->addFilter(new EqualsAnyFilter('handlerIdentifier', $handlers)); - /** @var string[] $ids */ + /** @var list $ids */ $ids = $this->paymentMethodRepository->searchIds($criteria, $context)->getIds(); if (!$ids) { diff --git a/src/Webhook/Registration/WebhookSubscriber.php b/src/Webhook/Registration/WebhookSubscriber.php index 52bb8d3f4..e1b00b01f 100644 --- a/src/Webhook/Registration/WebhookSubscriber.php +++ b/src/Webhook/Registration/WebhookSubscriber.php @@ -96,7 +96,7 @@ public function checkWebhookAfter(SystemConfigMultipleChangedEvent $event): void */ private function getConfigToCheck(BeforeSystemConfigMultipleChangedEvent|SystemConfigMultipleChangedEvent $event): ?array { - /** @var array $config */ + /** @var array $config */ $config = $event->getConfig(); $routeName = (string) $this->requestStack->getMainRequest()?->attributes->getString('_route'); diff --git a/tests/AgentCommerce/HoneyClientMock.php b/tests/AgentCommerce/HoneyClientMock.php new file mode 100644 index 000000000..f8f62ae79 --- /dev/null +++ b/tests/AgentCommerce/HoneyClientMock.php @@ -0,0 +1,61 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\Test\AgentCommerce; + +use GuzzleHttp\ClientInterface; +use GuzzleHttp\Promise\Promise; +use GuzzleHttp\Promise\PromiseInterface; +use GuzzleHttp\Psr7\Response; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; +use Shopware\Core\Framework\Log\Package; + +/** + * @internal + */ +#[Package('checkout')] +class HoneyClientMock implements ClientInterface +{ + public function __construct( + private readonly array $config + ) { + } + + public function send(RequestInterface $request, array $options = []): ResponseInterface + { + return new Response(); + } + + public function sendAsync(RequestInterface $request, array $options = []): PromiseInterface + { + return new Promise(); + } + + public function requestAsync(string $method, $uri, array $options = []): PromiseInterface + { + return new Promise(); + } + + public function request(string $method, $uri, array $options = []): ResponseInterface + { + return new Response(); + } + + public function getConfig(?string $option = null) + { + if ($option !== null) { + if (isset($this->config[$option])) { + return $this->config[$option]; + } + + return null; + } + + return $this->config; + } +} diff --git a/tests/AgentCommerce/HoneyWebhookServiceTest.php b/tests/AgentCommerce/HoneyWebhookServiceTest.php new file mode 100644 index 000000000..38ed0d7b5 --- /dev/null +++ b/tests/AgentCommerce/HoneyWebhookServiceTest.php @@ -0,0 +1,670 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\Test\AgentCommerce; + +use GuzzleHttp\Exception\ClientException; +use GuzzleHttp\Exception\RequestException; +use GuzzleHttp\Psr7\Request; +use GuzzleHttp\Psr7\Response; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use Shopware\Core\Content\ProductExport\ProductExportCollection; +use Shopware\Core\Content\ProductExport\ProductExportEntity; +use Shopware\Core\Framework\Context; +use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository; +use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria; +use Shopware\Core\Framework\DataAbstractionLayer\Search\EntitySearchResult; +use Shopware\Core\Framework\JWT\JWTDecoder; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Uuid\Uuid; +use Shopware\Core\System\Country\CountryCollection; +use Shopware\Core\System\Country\CountryEntity; +use Shopware\Core\System\Currency\CurrencyEntity; +use Shopware\Core\System\SalesChannel\Aggregate\SalesChannelDomain\SalesChannelDomainCollection; +use Shopware\Core\System\SalesChannel\Aggregate\SalesChannelDomain\SalesChannelDomainEntity; +use Shopware\Core\System\SalesChannel\SalesChannelCollection; +use Shopware\Core\System\SalesChannel\SalesChannelEntity; +use Shopware\Core\System\SystemConfig\SystemConfigService; +use Swag\PayPal\AgentCommerce\Exception\HoneyWebhookException; +use Swag\PayPal\AgentCommerce\HoneyWebhookService; +use Swag\PayPal\AgentCommerce\Util\FaviconLoader; +use Swag\PayPal\Setting\Service\CredentialsUtil; +use Swag\PayPal\Setting\Settings; +use Swag\PayPal\SwagPayPal; +use Symfony\Component\Routing\Route; +use Symfony\Component\Routing\RouteCollection; +use Symfony\Component\Routing\RouterInterface; + +/** + * @internal + */ +#[Package('checkout')] +#[CoversClass(HoneyWebhookService::class)] +class HoneyWebhookServiceTest extends TestCase +{ + public function testRegisterWebhook(): void + { + $context = Context::createCLIContext(); + $salesChannel = self::createSalesChannel(); + $salesChannelRepository = $this->createMock(EntityRepository::class); + $salesChannelRepository + ->expects($this->once()) + ->method('search') + ->willReturn(new EntitySearchResult('sales_channel', 1, new SalesChannelCollection([$salesChannel]), null, new Criteria(), $context)); + + $client = $this->createMock(HoneyClientMock::class); + $client + ->expects($this->once()) + ->method('request') + ->with('POST', 'webhooks/sw/install') + ->willReturnCallback(function (string $method, string $url, array $options) use ($salesChannel) { + $jwt = (new JWTDecoder())->decode($options['body']); + + static::assertSame('SalesChannel name', $jwt['storeName']); + static::assertSame('https://example.com/', $jwt['storeUrl']); + static::assertSame('DE', $jwt['country']); + static::assertSame('EUR', $jwt['currency']); + static::assertSame('https://example.com/favicon.ico', $jwt['favIcon']); + static::assertSame(['DE', 'UK'], $jwt['shippingCountries']); + static::assertSame('SomeMerchantId', $jwt['paypalMerchantId']); + static::assertSame($salesChannel->getId(), $jwt['shopwareMerchantId']); + static::assertSame('https://example.com/test/path/export', $jwt['catalogDownloadUrl']); + + return new Response(body: (string) json_encode(['message' => 'Merchant onboarded successfully'])); + }); + + $credentialsUtil = $this->createMock(CredentialsUtil::class); + $credentialsUtil + ->expects($this->once()) + ->method('getMerchantPayerId') + ->willReturn('SomeMerchantId'); + + $route = new Route('/test/path/export'); + $routeCollection = new RouteCollection(); + $routeCollection->add('store-api.product.export', $route); + + $routeMock = $this->createMock(RouterInterface::class); + $routeMock + ->expects($this->once()) + ->method('getRouteCollection') + ->willReturn($routeCollection); + + $configServiceMock = $this->createMock(SystemConfigService::class); + $configServiceMock + ->expects($this->once()) + ->method('get') + ->with(Settings::AGENT_COMMERCE_ONBOARDED) + ->willReturn(false); + $configServiceMock + ->expects($this->once()) + ->method('set') + ->with(Settings::AGENT_COMMERCE_ONBOARDED, true); + + $loggerMock = $this->createMock(LoggerInterface::class); + $loggerMock + ->expects($this->once()) + ->method('log') + ->with('info', 'PayPal agent commerce webhook install', [ + 'success' => true, + 'message' => 'Merchant onboarded successfully', + 'error' => null, + ]); + + $faviconMock = $this->createMock(FaviconLoader::class); + $faviconMock + ->expects($this->once()) + ->method('loadFaviconLink') + ->willReturn('https://example.com/favicon.ico'); + + $service = new HoneyWebhookService( + $client, + $salesChannelRepository, + $credentialsUtil, + $routeMock, + $configServiceMock, + $loggerMock, + $faviconMock + ); + + $result = $service->register($salesChannel->getId(), $context); + + static::assertTrue($result->success); + static::assertSame('Merchant onboarded successfully', $result->message); + } + + public function testDeregisterWebhook(): void + { + $client = $this->createMock(HoneyClientMock::class); + $client + ->expects($this->once()) + ->method('request') + ->with('POST', 'webhooks/sw/uninstall') + ->willReturnCallback(function (string $method, string $url, array $options) { + $jwt = (new JWTDecoder())->decode($options['body']); + + static::assertSame('SalesChannel name', $jwt['storeName']); + static::assertSame('https://example.com/', $jwt['storeUrl']); + static::assertSame('DE', $jwt['country']); + static::assertSame('EUR', $jwt['currency']); + static::assertEmpty(array_diff(['DE', 'UK'], $jwt['shippingCountries'])); + static::assertSame('SomeMerchantId', $jwt['paypalMerchantId']); + static::assertSame('019980f9426c716baa53befcd0879fb4', $jwt['shopwareMerchantId']); + static::assertSame('https://example.com/test/path/export', $jwt['catalogDownloadUrl']); + + return new Response(body: (string) json_encode(['message' => 'Merchant onboarded successfully'])); + }); + + $configServiceMock = $this->createMock(SystemConfigService::class); + $configServiceMock + ->expects($this->once()) + ->method('get') + ->with(Settings::AGENT_COMMERCE_ONBOARDED) + ->willReturn('eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdG9yZU5hbWUiOiJTYWxlc0NoYW5uZWwgbmFtZSIsInN0b3JlVXJsIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS8iLCJjb3VudHJ5IjoiREUiLCJjdXJyZW5jeSI6IkVVUiIsImZhdkljb24iOiJodHRwczovL2xvY2FsaG9zdC9mYXZpY29uLmljbyIsInNoaXBwaW5nQ291bnRyaWVzIjp7IjAxOTk4MGY5NDI2YzcxNmJhYTUzYmVmY2NjODRjNWM2IjoiREUiLCIwMTk5ODBmOTQyNmM3MTZiYWE1M2JlZmNjZDI4Y2Q3ZiI6IlVLIn0sInBheXBhbE1lcmNoYW50SWQiOiJTb21lTWVyY2hhbnRJZCIsInNob3B3YXJlTWVyY2hhbnRJZCI6IjAxOTk4MGY5NDI2YzcxNmJhYTUzYmVmY2QwODc5ZmI0IiwiY2F0YWxvZ0Rvd25sb2FkVXJsIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS90ZXN0L3BhdGgvZXhwb3J0In0.3K5rXCZGBgNFWOmZwTkVOV5AhCrr8VKgAS5ZPqsKeHI'); + $configServiceMock + ->expects($this->once()) + ->method('delete') + ->with(Settings::AGENT_COMMERCE_ONBOARDED); + + $loggerMock = $this->createMock(LoggerInterface::class); + $loggerMock + ->expects($this->once()) + ->method('log') + ->with('info', 'PayPal agent commerce webhook uninstall', [ + 'success' => true, + 'message' => 'Merchant onboarded successfully', + 'error' => null, + ]); + + $service = new HoneyWebhookService( + $client, + $this->createMock(EntityRepository::class), + $this->createMock(CredentialsUtil::class), + $this->createMock(RouterInterface::class), + $configServiceMock, + $loggerMock, + $this->createMock(FaviconLoader::class) + ); + + $result = $service->deregister('019980f9426c716baa53befcd0879fb4'); + + static::assertTrue($result->success); + static::assertSame('Merchant onboarded successfully', $result->message); + } + + public function testReRegister(): void + { + $context = Context::createCLIContext(); + $salesChannel = self::createSalesChannel(); + $salesChannelRepository = $this->createMock(EntityRepository::class); + $salesChannelRepository + ->expects($this->once()) + ->method('search') + ->willReturn(new EntitySearchResult('sales_channel', 1, new SalesChannelCollection([$salesChannel]), null, new Criteria(), $context)); + + $client = $this->createMock(HoneyClientMock::class); + $client + ->expects($this->exactly(2)) + ->method('request') + ->willReturnCallback(function (string $method, string $url, array $options) use ($salesChannel) { + $jwt = (new JWTDecoder())->decode($options['body']); + + static::assertSame('SalesChannel name', $jwt['storeName']); + static::assertSame('https://example.com/', $jwt['storeUrl']); + static::assertSame('DE', $jwt['country']); + static::assertSame('EUR', $jwt['currency']); + static::assertEmpty(array_diff(['DE', 'UK'], $jwt['shippingCountries'])); + static::assertSame('SomeMerchantId', $jwt['paypalMerchantId']); + static::assertSame($salesChannel->getId(), $jwt['shopwareMerchantId']); + static::assertSame('https://example.com/test/path/export', $jwt['catalogDownloadUrl']); + + return new Response(body: (string) json_encode(['message' => 'Merchant onboarded successfully'])); + }); + + $credentialsUtil = $this->createMock(CredentialsUtil::class); + $credentialsUtil + ->method('getMerchantPayerId') + ->willReturn('SomeMerchantId'); + + $route = new Route('/test/path/export'); + $routeCollection = new RouteCollection(); + $routeCollection->add('store-api.product.export', $route); + + $routeMock = $this->createMock(RouterInterface::class); + $routeMock + ->method('getRouteCollection') + ->willReturn($routeCollection); + + $configServiceMock = $this->createMock(SystemConfigService::class); + $configServiceMock + ->method('get') + ->with(Settings::AGENT_COMMERCE_ONBOARDED) + ->willReturn('eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdG9yZU5hbWUiOiJTYWxlc0NoYW5uZWwgbmFtZSIsInN0b3JlVXJsIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS8iLCJjb3VudHJ5IjoiREUiLCJjdXJyZW5jeSI6IkVVUiIsImZhdkljb24iOiJodHRwczovL2xvY2FsaG9zdC9mYXZpY29uLmljbyIsInNoaXBwaW5nQ291bnRyaWVzIjp7IjAxOTk4MGY5NDI2YzcxNmJhYTUzYmVmY2NjODRjNWM2IjoiREUiLCIwMTk5ODBmOTQyNmM3MTZiYWE1M2JlZmNjZDI4Y2Q3ZiI6IlVLIn0sInBheXBhbE1lcmNoYW50SWQiOiJTb21lTWVyY2hhbnRJZCIsInNob3B3YXJlTWVyY2hhbnRJZCI6IjAxOTk4MGY5NDI2YzcxNmJhYTUzYmVmY2QwODc5ZmI0IiwiY2F0YWxvZ0Rvd25sb2FkVXJsIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS90ZXN0L3BhdGgvZXhwb3J0In0.3K5rXCZGBgNFWOmZwTkVOV5AhCrr8VKgAS5ZPqsKeHI'); + $configServiceMock + ->expects($this->once()) + ->method('set') + ->with(Settings::AGENT_COMMERCE_ONBOARDED); + + $loggerMock = $this->createMock(LoggerInterface::class); + $loggerMock + ->expects($this->exactly(2)) + ->method('log'); + + $service = new HoneyWebhookService( + $client, + $salesChannelRepository, + $credentialsUtil, + $routeMock, + $configServiceMock, + $loggerMock, + $this->createMock(FaviconLoader::class) + ); + + $result = $service->register($salesChannel->getId(), $context); + + static::assertTrue($result->success); + static::assertSame('Merchant onboarded successfully', $result->message); + } + + public function testFailedReRegister(): void + { + $this->expectException(HoneyWebhookException::class); + $this->expectExceptionMessage('JWT signature verification failed'); + + $context = Context::createCLIContext(); + $salesChannel = self::createSalesChannel(); + $salesChannelRepository = $this->createMock(EntityRepository::class); + $salesChannelRepository + ->expects($this->once()) + ->method('search') + ->willReturn(new EntitySearchResult('sales_channel', 1, new SalesChannelCollection([$salesChannel]), null, new Criteria(), $context)); + + $client = $this->createMock(HoneyClientMock::class); + $client + ->expects($this->exactly(2)) + ->method('request') + ->willReturnCallback(function (): void { + $response = new Response(400, body: (string) json_encode([ + 'success' => false, + 'error' => 'INVALID_JWT', + 'message' => 'JWT signature verification failed', + ])); + + throw new ClientException('Something went wrong', new Request('POST', 'webhooks/sw/uninstall'), $response); + }); + + $route = new Route('/test/path/export'); + $routeCollection = new RouteCollection(); + $routeCollection->add('store-api.product.export', $route); + + $routeMock = $this->createMock(RouterInterface::class); + $routeMock + ->method('getRouteCollection') + ->willReturn($routeCollection); + + $configServiceMock = $this->createMock(SystemConfigService::class); + $configServiceMock + ->method('get') + ->with(Settings::AGENT_COMMERCE_ONBOARDED) + ->willReturn('eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdG9yZU5hbWUiOiJTYWxlc0NoYW5uZWwgbmFtZSIsInN0b3JlVXJsIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS8iLCJjb3VudHJ5IjoiREUiLCJjdXJyZW5jeSI6IkVVUiIsImZhdkljb24iOiJodHRwczovL2xvY2FsaG9zdC9mYXZpY29uLmljbyIsInNoaXBwaW5nQ291bnRyaWVzIjp7IjAxOTk4MGY5NDI2YzcxNmJhYTUzYmVmY2NjODRjNWM2IjoiREUiLCIwMTk5ODBmOTQyNmM3MTZiYWE1M2JlZmNjZDI4Y2Q3ZiI6IlVLIn0sInBheXBhbE1lcmNoYW50SWQiOiJTb21lTWVyY2hhbnRJZCIsInNob3B3YXJlTWVyY2hhbnRJZCI6IjAxOTk4MGY5NDI2YzcxNmJhYTUzYmVmY2QwODc5ZmI0IiwiY2F0YWxvZ0Rvd25sb2FkVXJsIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS90ZXN0L3BhdGgvZXhwb3J0In0.3K5rXCZGBgNFWOmZwTkVOV5AhCrr8VKgAS5ZPqsKeHI'); + $configServiceMock + ->expects($this->never()) + ->method('set') + ->with(Settings::AGENT_COMMERCE_ONBOARDED, true); + + $loggerMock = $this->createMock(LoggerInterface::class); + $loggerMock + ->expects($this->exactly(2)) + ->method('log'); + + $service = new HoneyWebhookService( + $client, + $salesChannelRepository, + $this->createMock(CredentialsUtil::class), + $routeMock, + $configServiceMock, + $loggerMock, + $this->createMock(FaviconLoader::class) + ); + + $service->register($salesChannel->getId(), $context); + } + + public function testDeregisterNotRegistered(): void + { + $this->expectException(HoneyWebhookException::class); + $this->expectExceptionMessage('Sales channel is not registered and can\'t be deregistered'); + + $client = $this->createMock(HoneyClientMock::class); + $client + ->expects($this->never()) + ->method('request'); + + $configServiceMock = $this->createMock(SystemConfigService::class); + $configServiceMock + ->expects($this->once()) + ->method('get') + ->with(Settings::AGENT_COMMERCE_ONBOARDED) + ->willReturn(null); + $configServiceMock + ->expects($this->never()) + ->method('delete') + ->with(Settings::AGENT_COMMERCE_ONBOARDED); + + $service = new HoneyWebhookService( + $client, + $this->createMock(EntityRepository::class), + $this->createMock(CredentialsUtil::class), + $this->createMock(RouterInterface::class), + $configServiceMock, + $this->createMock(LoggerInterface::class), + $this->createMock(FaviconLoader::class) + ); + + $service->deregister(Uuid::randomHex()); + } + + #[DataProvider('dataProviderMissingSalesChannelDataRegister')] + public function testMissingSalesChannelDataRegister(?SalesChannelEntity $salesChannel, string $exceptionMessage): void + { + $this->expectException(HoneyWebhookException::class); + $this->expectExceptionMessage($exceptionMessage); + + $searchResult = new EntitySearchResult('sales_channel', 0, new SalesChannelCollection(), null, new Criteria(), Context::createCLIContext()); + if ($salesChannel) { + $searchResult = new EntitySearchResult('sales_channel', 1, new SalesChannelCollection([$salesChannel]), null, new Criteria(), Context::createCLIContext()); + } + + $context = Context::createCLIContext(); + $salesChannel = self::createSalesChannel(); + $salesChannelRepository = $this->createMock(EntityRepository::class); + $salesChannelRepository + ->expects($this->once()) + ->method('search') + ->willReturn($searchResult); + + $routeMock = $this->createMock(RouterInterface::class); + $routeMock + ->method('getRouteCollection') + ->willReturn(new RouteCollection()); + + $configServiceMock = $this->createMock(SystemConfigService::class); + $configServiceMock + ->method('get') + ->with(Settings::AGENT_COMMERCE_ONBOARDED) + ->willReturn(null); + + $client = $this->createMock(HoneyClientMock::class); + $client + ->expects($this->never()) + ->method('request'); + + $service = new HoneyWebhookService( + $client, + $salesChannelRepository, + $this->createMock(CredentialsUtil::class), + $routeMock, + $configServiceMock, + $this->createMock(LoggerInterface::class), + $this->createMock(FaviconLoader::class) + ); + + $service->register($salesChannel->getId(), $context); + } + + public static function dataProviderMissingSalesChannelDataRegister(): \Generator + { + yield 'no sales channel found' => [null, 'Agent commerce sales channel not found']; + + $salesChannel = self::createSalesChannel(); + $salesChannel->setActive(false); + yield 'no sales channel not active' => [$salesChannel, 'Agent commerce sales channel not found']; + + $salesChannel = self::createSalesChannel(); + $salesChannel->setProductExports(new ProductExportCollection()); + yield 'no export' => [$salesChannel, 'Product export sales channel not found']; + + $productExport = new ProductExportEntity(); + $productExport->setId(Uuid::randomHex()); + + $salesChannel = self::createSalesChannel(); + $salesChannel->setProductExports(new ProductExportCollection([$productExport])); + yield 'no storefront sales channel' => [$salesChannel, 'Storefront sales channel not found']; + + $salesChannel = self::createSalesChannel(); + yield 'no route' => [$salesChannel, 'Invalid product export route']; + } + + public function testInvalidRegisterRequest(): void + { + $this->expectException(HoneyWebhookException::class); + $this->expectExceptionMessage('JWT signature verification failed'); + + $context = Context::createCLIContext(); + $salesChannel = self::createSalesChannel(); + $salesChannelRepository = $this->createMock(EntityRepository::class); + $salesChannelRepository + ->expects($this->once()) + ->method('search') + ->willReturn(new EntitySearchResult('sales_channel', 1, new SalesChannelCollection([$salesChannel]), null, new Criteria(), $context)); + + $client = $this->createMock(HoneyClientMock::class); + $client + ->expects($this->once()) + ->method('request') + ->with('POST', 'webhooks/sw/install') + ->willReturnCallback(function (): void { + $response = new Response(400, body: (string) json_encode([ + 'success' => false, + 'error' => 'INVALID_JWT', + 'message' => 'JWT signature verification failed', + ])); + + throw new ClientException('Something went wrong', new Request('POST', 'webhooks/sw/install'), $response); + }); + + $route = new Route('/test/path/export'); + $routeCollection = new RouteCollection(); + $routeCollection->add('store-api.product.export', $route); + + $routeMock = $this->createMock(RouterInterface::class); + $routeMock + ->expects($this->once()) + ->method('getRouteCollection') + ->willReturn($routeCollection); + + $configServiceMock = $this->createMock(SystemConfigService::class); + $configServiceMock + ->expects($this->once()) + ->method('get') + ->with(Settings::AGENT_COMMERCE_ONBOARDED) + ->willReturn(null); + $configServiceMock + ->expects($this->once()) + ->method('delete') + ->with(Settings::AGENT_COMMERCE_ONBOARDED); + + $loggerMock = $this->createMock(LoggerInterface::class); + $loggerMock + ->expects($this->once()) + ->method('log') + ->with('error', 'PayPal agent commerce webhook install', [ + 'success' => false, + 'message' => 'JWT signature verification failed', + 'error' => 'INVALID_JWT', + ]); + + $service = new HoneyWebhookService( + $client, + $salesChannelRepository, + $this->createMock(CredentialsUtil::class), + $routeMock, + $configServiceMock, + $loggerMock, + $this->createMock(FaviconLoader::class) + ); + + $service->register($salesChannel->getId(), $context); + } + + public function testInvalidDeregisterRequest(): void + { + $this->expectException(HoneyWebhookException::class); + $this->expectExceptionMessage('JWT signature verification failed'); + + $client = $this->createMock(HoneyClientMock::class); + $client + ->expects($this->once()) + ->method('request') + ->with('POST', 'webhooks/sw/uninstall') + ->willReturnCallback(function (): void { + $response = new Response(400, body: (string) json_encode([ + 'success' => false, + 'error' => 'INVALID_JWT', + 'message' => 'JWT signature verification failed', + ])); + + throw new ClientException('Something went wrong', new Request('POST', 'webhooks/sw/uninstall'), $response); + }); + + $configServiceMock = $this->createMock(SystemConfigService::class); + $configServiceMock + ->expects($this->once()) + ->method('get') + ->with(Settings::AGENT_COMMERCE_ONBOARDED) + ->willReturn('eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdG9yZU5hbWUiOiJTYWxlc0NoYW5uZWwgbmFtZSIsInN0b3JlVXJsIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS8iLCJjb3VudHJ5IjoiREUiLCJjdXJyZW5jeSI6IkVVUiIsImZhdkljb24iOiJodHRwczovL2xvY2FsaG9zdC9mYXZpY29uLmljbyIsInNoaXBwaW5nQ291bnRyaWVzIjp7IjAxOTk4MGY5NDI2YzcxNmJhYTUzYmVmY2NjODRjNWM2IjoiREUiLCIwMTk5ODBmOTQyNmM3MTZiYWE1M2JlZmNjZDI4Y2Q3ZiI6IlVLIn0sInBheXBhbE1lcmNoYW50SWQiOiJTb21lTWVyY2hhbnRJZCIsInNob3B3YXJlTWVyY2hhbnRJZCI6IjAxOTk4MGY5NDI2YzcxNmJhYTUzYmVmY2QwODc5ZmI0IiwiY2F0YWxvZ0Rvd25sb2FkVXJsIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS90ZXN0L3BhdGgvZXhwb3J0In0.3K5rXCZGBgNFWOmZwTkVOV5AhCrr8VKgAS5ZPqsKeHI'); + $configServiceMock + ->expects($this->never()) + ->method('set'); + + $loggerMock = $this->createMock(LoggerInterface::class); + $loggerMock + ->expects($this->once()) + ->method('log') + ->with('error', 'PayPal agent commerce webhook uninstall', [ + 'success' => false, + 'message' => 'JWT signature verification failed', + 'error' => 'INVALID_JWT', + ]); + + $service = new HoneyWebhookService( + $client, + $this->createMock(EntityRepository::class), + $this->createMock(CredentialsUtil::class), + $this->createMock(RouterInterface::class), + $configServiceMock, + $loggerMock, + $this->createMock(FaviconLoader::class) + ); + + $service->deregister(Uuid::randomHex()); + } + + public function testInvalidRequestNoResponse(): void + { + $this->expectException(RequestException::class); + $this->expectExceptionMessage('Something went wrong'); + + $context = Context::createCLIContext(); + $salesChannel = self::createSalesChannel(); + $salesChannelRepository = $this->createMock(EntityRepository::class); + $salesChannelRepository + ->expects($this->once()) + ->method('search') + ->willReturn(new EntitySearchResult('sales_channel', 1, new SalesChannelCollection([$salesChannel]), null, new Criteria(), $context)); + + $client = $this->createMock(HoneyClientMock::class); + $client + ->expects($this->once()) + ->method('request') + ->with('POST', 'webhooks/sw/install') + ->willReturnCallback(function (): void { + throw new RequestException('Something went wrong', new Request('POST', 'webhooks/sw/install')); + }); + + $route = new Route('/test/path/export'); + $routeCollection = new RouteCollection(); + $routeCollection->add('store-api.product.export', $route); + + $routeMock = $this->createMock(RouterInterface::class); + $routeMock + ->expects($this->once()) + ->method('getRouteCollection') + ->willReturn($routeCollection); + + $configServiceMock = $this->createMock(SystemConfigService::class); + $configServiceMock + ->expects($this->once()) + ->method('get') + ->with(Settings::AGENT_COMMERCE_ONBOARDED) + ->willReturn(false); + $configServiceMock + ->expects($this->never()) + ->method('delete') + ->with(Settings::AGENT_COMMERCE_ONBOARDED); + + $service = new HoneyWebhookService( + $client, + $salesChannelRepository, + $this->createMock(CredentialsUtil::class), + $routeMock, + $configServiceMock, + $this->createMock(LoggerInterface::class), + $this->createMock(FaviconLoader::class) + ); + + $result = $service->register($salesChannel->getId(), $context); + + static::assertFalse($result->success); + static::assertSame('INVALID_JWT', $result->error); + static::assertSame('JWT signature verification failed', $result->message); + } + + private static function createSalesChannel(): SalesChannelEntity + { + $de = new CountryEntity(); + $de->setId(Uuid::randomHex()); + $de->setIso('DE'); + $uk = new CountryEntity(); + $uk->setId(Uuid::randomHex()); + $uk->setIso('UK'); + + $eur = new CurrencyEntity(); + $eur->setId(Uuid::randomHex()); + $eur->setIsoCode('EUR'); + + $domain = new SalesChannelDomainEntity(); + $domain->setId(Uuid::randomHex()); + $domain->setUrl('https://example.com/'); // with "/" to test rtrim + + $storefrontSalesChannel = new SalesChannelEntity(); + $storefrontSalesChannel->setId(Uuid::randomHex()); + $storefrontSalesChannel->setCountries(new CountryCollection([$de, $uk])); + $storefrontSalesChannel->setDomains(new SalesChannelDomainCollection([$domain])); + $storefrontSalesChannel->setHreflangDefaultDomain($domain); + + $productExport = new ProductExportEntity(); + $productExport->setId(Uuid::randomHex()); + $productExport->setStorefrontSalesChannelId($storefrontSalesChannel->getId()); + $productExport->setStorefrontSalesChannel($storefrontSalesChannel); + $productExport->setAccessKey(Uuid::randomHex()); + $productExport->setFileName('test.test'); + + $salesChannel = new SalesChannelEntity(); + $salesChannel->setId('019980f9426c716baa53befcd0879fb4'); + $salesChannel->setActive(true); + $salesChannel->setTypeId(SwagPayPal::SALES_CHANNEL_TYPE_AGENT_COMMERCE); + $salesChannel->setProductExports(new ProductExportCollection([$productExport])); + $salesChannel->setCountry($de); + $salesChannel->setCurrency($eur); + $salesChannel->setTranslated(['name' => 'SalesChannel name']); + + return $salesChannel; + } +} diff --git a/tests/AgentCommerce/Routing/AgentContextResolverListenerTest.php b/tests/AgentCommerce/Routing/AgentContextResolverListenerTest.php new file mode 100644 index 000000000..337368dca --- /dev/null +++ b/tests/AgentCommerce/Routing/AgentContextResolverListenerTest.php @@ -0,0 +1,55 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\Tests\AgentCommerce\Routing; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Routing\KernelListenerPriorities; +use Shopware\Core\Framework\Routing\RequestContextResolverInterface; +use Shopware\Core\Test\Stub\Symfony\StubKernel; +use Swag\PayPal\AgentCommerce\Routing\AgentContextResolverListener; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Event\ControllerEvent; +use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\HttpKernel\KernelEvents; + +/** + * @internal + */ +#[Package('checkout')] +#[CoversClass(AgentContextResolverListener::class)] +class AgentContextResolverListenerTest extends TestCase +{ + public function testGetSubscribedEvents(): void + { + static::assertSame( + [ + KernelEvents::CONTROLLER => [ + ['resolveContext', KernelListenerPriorities::KERNEL_CONTROLLER_EVENT_CONTEXT_RESOLVE], + ], + ], + AgentContextResolverListener::getSubscribedEvents() + ); + } + + public function testResolveContext(): void + { + $request = new Request(); + $event = new ControllerEvent(new StubKernel(), function (): void {}, $request, HttpKernelInterface::MAIN_REQUEST); + + $resolver = $this->createMock(RequestContextResolverInterface::class); + $resolver + ->expects($this->once()) + ->method('resolve') + ->with($request); + + $listener = new AgentContextResolverListener($resolver); + $listener->resolveContext($event); + } +} diff --git a/tests/AgentCommerce/Routing/AgentRequestContextResolverTest.php b/tests/AgentCommerce/Routing/AgentRequestContextResolverTest.php new file mode 100644 index 000000000..35f53e538 --- /dev/null +++ b/tests/AgentCommerce/Routing/AgentRequestContextResolverTest.php @@ -0,0 +1,562 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\Tests\AgentCommerce\Routing; + +use Lcobucci\JWT\Configuration; +use Lcobucci\JWT\Signer\Key\InMemory; +use Lcobucci\JWT\Signer\Rsa\Sha256; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Shopware\Core\Content\ProductExport\ProductExportCollection; +use Shopware\Core\Content\ProductExport\ProductExportDefinition; +use Shopware\Core\Content\ProductExport\ProductExportEntity; +use Shopware\Core\Defaults; +use Shopware\Core\Framework\Context; +use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository; +use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria; +use Shopware\Core\Framework\DataAbstractionLayer\Search\EntitySearchResult; +use Shopware\Core\Framework\JWT\JWTDecoder; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Routing\RouteScope; +use Shopware\Core\Framework\Routing\RouteScopeRegistry; +use Shopware\Core\Framework\Test\TestCaseBase\BasicTestDataBehaviour; +use Shopware\Core\Framework\Test\TestCaseBase\DatabaseTransactionBehaviour; +use Shopware\Core\Framework\Test\TestCaseBase\KernelTestBehaviour; +use Shopware\Core\Framework\Test\TestCaseBase\SalesChannelApiTestBehaviour; +use Shopware\Core\Framework\Uuid\Uuid; +use Shopware\Core\Framework\Validation\DataValidator; +use Shopware\Core\PlatformRequest; +use Shopware\Core\System\SalesChannel\Context\SalesChannelContextService; +use Shopware\Core\System\SalesChannel\SalesChannelContext; +use Shopware\Core\Test\Generator; +use Swag\PayPal\AgentCommerce\Exception\AgentException; +use Swag\PayPal\AgentCommerce\Routing\AgentRequestContextResolver; +use Swag\PayPal\AgentCommerce\Routing\AgentRouteScope; +use Swag\PayPal\AgentCommerce\Routing\AgentSource; +use Swag\PayPal\SwagPayPal; +use Symfony\Component\HttpFoundation\Request; + +/** + * @internal + */ +#[Package('checkout')] +#[CoversClass(AgentRequestContextResolver::class)] +class AgentRequestContextResolverTest extends TestCase +{ + use BasicTestDataBehaviour; + use DatabaseTransactionBehaviour; + use KernelTestBehaviour; + use SalesChannelApiTestBehaviour; + + private const JWT_PUBLIC = '-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1SU1LfVLPHCozMxH2Mo +4lgOEePzNm0tRgeLezV6ffAt0gunVTLw7onLRnrq0/IzW7yWR7QkrmBL7jTKEn5u ++qKhbwKfBstIs+bMY2Zkp18gnTxKLxoS2tFczGkPLPgizskuemMghRniWaoLcyeh +kd3qqGElvW/VDL5AaWTg0nLVkjRo9z+40RQzuVaE8AkAFmxZzow3x+VJYKdjykkJ +0iT9wCS0DRTXu269V264Vf/3jvredZiKRkgwlL9xNAwxXFg0x/XFw005UWVRIkdg +cKWTjpBP2dPwVZ4WWC+9aGVd+Gyn1o0CLelf4rEjGoXbAAEgAqeGUxrcIlbjXfbc +mwIDAQAB +-----END PUBLIC KEY-----'; + + private const JWT_PRIVATE = '-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQC7VJTUt9Us8cKj +MzEfYyjiWA4R4/M2bS1GB4t7NXp98C3SC6dVMvDuictGeurT8jNbvJZHtCSuYEvu +NMoSfm76oqFvAp8Gy0iz5sxjZmSnXyCdPEovGhLa0VzMaQ8s+CLOyS56YyCFGeJZ +qgtzJ6GR3eqoYSW9b9UMvkBpZODSctWSNGj3P7jRFDO5VoTwCQAWbFnOjDfH5Ulg +p2PKSQnSJP3AJLQNFNe7br1XbrhV//eO+t51mIpGSDCUv3E0DDFcWDTH9cXDTTlR +ZVEiR2BwpZOOkE/Z0/BVnhZYL71oZV34bKfWjQIt6V/isSMahdsAASACp4ZTGtwi +VuNd9tybAgMBAAECggEBAKTmjaS6tkK8BlPXClTQ2vpz/N6uxDeS35mXpqasqskV +laAidgg/sWqpjXDbXr93otIMLlWsM+X0CqMDgSXKejLS2jx4GDjI1ZTXg++0AMJ8 +sJ74pWzVDOfmCEQ/7wXs3+cbnXhKriO8Z036q92Qc1+N87SI38nkGa0ABH9CN83H +mQqt4fB7UdHzuIRe/me2PGhIq5ZBzj6h3BpoPGzEP+x3l9YmK8t/1cN0pqI+dQwY +dgfGjackLu/2qH80MCF7IyQaseZUOJyKrCLtSD/Iixv/hzDEUPfOCjFDgTpzf3cw +ta8+oE4wHCo1iI1/4TlPkwmXx4qSXtmw4aQPz7IDQvECgYEA8KNThCO2gsC2I9PQ +DM/8Cw0O983WCDY+oi+7JPiNAJwv5DYBqEZB1QYdj06YD16XlC/HAZMsMku1na2T +N0driwenQQWzoev3g2S7gRDoS/FCJSI3jJ+kjgtaA7Qmzlgk1TxODN+G1H91HW7t +0l7VnL27IWyYo2qRRK3jzxqUiPUCgYEAx0oQs2reBQGMVZnApD1jeq7n4MvNLcPv +t8b/eU9iUv6Y4Mj0Suo/AU8lYZXm8ubbqAlwz2VSVunD2tOplHyMUrtCtObAfVDU +AhCndKaA9gApgfb3xw1IKbuQ1u4IF1FJl3VtumfQn//LiH1B3rXhcdyo3/vIttEk +48RakUKClU8CgYEAzV7W3COOlDDcQd935DdtKBFRAPRPAlspQUnzMi5eSHMD/ISL +DY5IiQHbIH83D4bvXq0X7qQoSBSNP7Dvv3HYuqMhf0DaegrlBuJllFVVq9qPVRnK +xt1Il2HgxOBvbhOT+9in1BzA+YJ99UzC85O0Qz06A+CmtHEy4aZ2kj5hHjECgYEA +mNS4+A8Fkss8Js1RieK2LniBxMgmYml3pfVLKGnzmng7H2+cwPLhPIzIuwytXywh +2bzbsYEfYx3EoEVgMEpPhoarQnYPukrJO4gwE2o5Te6T5mJSZGlQJQj9q4ZB2Dfz +et6INsK0oG8XVGXSpQvQh3RUYekCZQkBBFcpqWpbIEsCgYAnM3DQf3FJoSnXaMhr +VBIovic5l0xFkEHskAjFTevO86Fsz1C2aSeRKSqGFoOQ0tmJzBEs1R6KqnHInicD +TQrKhArgLXX4v3CddjfTRJkFWDbE/CkvKZNOrcf1nhaGCPspRJj2KUkj1Fhl9Cnc +dn/RsYEONbwQSjIfMPkvxF+8HQ== +-----END PRIVATE KEY-----'; + + protected function setUp(): void + { + // TODO: Remove this when we have a way to retrieve the public key dynamically from PayPal + // Override this, as we use our own private key to sign JWTs during tests + AgentRequestContextResolver::$PAYPAL_JWT = self::JWT_PUBLIC; + } + + protected function tearDown(): void + { + // TODO: Remove this when we have a way to retrieve the public key dynamically from PayPal + // Reset the static variable to the original value, if it was changed during tests + AgentRequestContextResolver::$PAYPAL_JWT = self::JWT_PUBLIC; + } + + public function testResolveWithContextIsSkipped(): void + { + $context = Context::createDefaultContext(); + $request = new Request(); + $request->attributes->set(PlatformRequest::ATTRIBUTE_CONTEXT_OBJECT, $context); + + $resolver = new AgentRequestContextResolver( + $this->createMock(DataValidator::class), + $this->createMock(EntityRepository::class), + new JWTDecoder(), + new RouteScopeRegistry([]), + $this->createMock(SalesChannelContextService::class), + ); + + $resolver->resolve($request); + + static::assertSame($context, $request->attributes->get(PlatformRequest::ATTRIBUTE_CONTEXT_OBJECT)); + } + + public function testResolveWithWrongScopeDoesNothing(): void + { + $request = new Request(); + $request->attributes->set(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, ['wrong-scope']); + $request->attributes->set(AgentRouteScope::ATTRIBUTE_PAYPAL_AGENT_SCOPE, ['cart', 'checkout']); + + $wrongScope = $this->createMock(RouteScope::class); + $wrongScope + ->method('getId') + ->willReturn('wrong-scope'); + + $resolver = new AgentRequestContextResolver( + $this->createMock(DataValidator::class), + $this->createMock(EntityRepository::class), + new JWTDecoder(), + new RouteScopeRegistry([new AgentRouteScope(), $wrongScope]), + $this->createMock(SalesChannelContextService::class), + ); + + $resolver->resolve($request); + + static::assertFalse($request->attributes->has(PlatformRequest::ATTRIBUTE_CONTEXT_OBJECT)); + } + + public function testResolveWithMissingAuthorizationHeader(): void + { + $request = new Request(); + $request->attributes->set(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, [AgentRouteScope::ID]); + $request->attributes->set(AgentRouteScope::ATTRIBUTE_PAYPAL_AGENT_SCOPE, ['cart', 'checkout']); + + $resolver = new AgentRequestContextResolver( + $this->createMock(DataValidator::class), + $this->createMock(EntityRepository::class), + new JWTDecoder(), + new RouteScopeRegistry([new AgentRouteScope()]), + $this->createMock(SalesChannelContextService::class), + ); + + $this->expectExceptionObject(AgentException::unauthorized('Missing Authorization header')); + + $resolver->resolve($request); + } + + public function testResolveWithWrongPublicJWT(): void + { + $jwt = self::encodeJWT( + 'MERCHANT_ID', + new \DateTimeImmutable(), + new \DateTimeImmutable('+1 hour'), + ['cart', 'checkout'], + 'SALES_CHANNEL_ID' + ); + + $request = new Request(); + $request->headers->set('Authorization', $jwt); + $request->attributes->set(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, [AgentRouteScope::ID]); + $request->attributes->set(AgentRouteScope::ATTRIBUTE_PAYPAL_AGENT_SCOPE, ['cart', 'checkout']); + + // this is a wrong public key + // TODO: change this up in the future, but this has to do for now, as long we do not have a real public JWT key from PayPal + AgentRequestContextResolver::$PAYPAL_JWT = '-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1SU1LfVLPHCozMxH2Mf +4lgOEePzNm0tRgeLezV6ffAt0gunVTLw7onLRnrq0/IzW7yWR7QkrmBL7jTKEn5u ++qKhbwKfBstIs+bMY2Zkp18gnTxKLxoS2tFczGkPLPgizskuemMghRniWaoLcyeh +kd3qqGElvW/VDL5AaWTg0nLVkjRo9z+40RQzuVaE8AkAFmxZzow3x+VJYKdjykkJ +0iT9wCS0DRTXu269V264Vf/3jvredZiKRkgwlL9xNAwxXFg0x/XFw005UWVRIkdg +cKWTjpBP2dPwVZ4WWC+9aGVd+Gyn1o0CLelf4rEjGoXbAAEgAqeGUxrcIlbjXfbc +mwIDAQAB +-----END PUBLIC KEY-----'; + + $export = new ProductExportEntity(); + $export->setId(Uuid::randomHex()); + $export->setProductStreamId(Uuid::randomHex()); + $export->setStorefrontSalesChannelId(Uuid::randomHex()); + $entityRepository = $this->createMock(EntityRepository::class); + $entityRepository + ->expects($this->once()) + ->method('search') + ->willReturn(self::createSearchResult($export)); + + $resolver = new AgentRequestContextResolver( + $this->createMock(DataValidator::class), + $entityRepository, + new JWTDecoder(), + new RouteScopeRegistry([new AgentRouteScope()]), + $this->createMock(SalesChannelContextService::class), + ); + + $this->expectExceptionObject(AgentException::unauthorized('Invalid JWT')); + + $resolver->resolve($request); + } + + public function testResolveWithExpiredToken(): void + { + $salesChannelId = $this->createSalesChannelWithExport(); + + $iat = new \DateTimeImmutable('-2 hours'); + $exp = new \DateTimeImmutable('-1 hour'); + + $jwt = self::encodeJWT( + 'MERCHANT_ID', + $iat, + $exp, + ['cart', 'checkout'], + $salesChannelId, + ); + + $request = new Request(); + $request->headers->set('Authorization', $jwt); + $request->attributes->set(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, [AgentRouteScope::ID]); + $request->attributes->set(AgentRouteScope::ATTRIBUTE_PAYPAL_AGENT_SCOPE, ['cart', 'checkout']); + + $this->expectExceptionObject(AgentException::unauthorized('Invalid JWT token')); + + $resolver = $this->getContainer()->get(AgentRequestContextResolver::class); + $resolver->resolve($request); + } + + public function testResolveWithWrongJWTHeader(): void + { + $request = new Request(); + $request->headers->set('Authorization', 'ey.wrong.jwt'); + $request->attributes->set(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, [AgentRouteScope::ID]); + $request->attributes->set(AgentRouteScope::ATTRIBUTE_PAYPAL_AGENT_SCOPE, ['cart', 'checkout']); + + $resolver = new AgentRequestContextResolver( + $this->createMock(DataValidator::class), + $this->createMock(EntityRepository::class), + new JWTDecoder(), + new RouteScopeRegistry([new AgentRouteScope()]), + $this->createMock(SalesChannelContextService::class), + ); + + $this->expectExceptionObject(AgentException::unauthorized('Invalid JWT token')); + + $resolver->resolve($request); + } + + /** + * @param array $claims + */ + #[DataProvider('malformedJWTProvider')] + public function testResolveWithMalformedJWTClaims(array $claims): void + { + $this->createSalesChannelWithExport(); + + $token = self::encodeJWT( + $claims['paypalMerchantId'] ?? null, + $claims['iat'] ?? null, + $claims['exp'] ?? null, + $claims['scope'] ?? null, + $claims['shopwareMerchantId'] ?? null + ); + + $request = new Request(); + $request->headers->set('Authorization', $token); + $request->attributes->set(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, [AgentRouteScope::ID]); + $request->attributes->set(AgentRouteScope::ATTRIBUTE_PAYPAL_AGENT_SCOPE, ['cart', 'checkout']); + + $this->expectExceptionObject(AgentException::unauthorized('Invalid JWT token')); + + $resolver = $this->getContainer()->get(AgentRequestContextResolver::class); + + $resolver->resolve($request); + } + + public static function malformedJWTProvider(): \Generator + { + yield 'Missing all' => [[]]; + + yield 'Missing paypalMerchantId' => [['iat' => new \DateTimeImmutable(), 'exp' => new \DateTimeImmutable('+1 hour'), 'scope' => ['cart', 'checkout'], 'shopwareMerchantId' => Uuid::randomHex()]]; + yield 'Missing iat' => [['paypalMerchantId' => 'MERCHANT_ID', 'exp' => new \DateTimeImmutable('+1 hour'), 'scope' => ['cart', 'checkout'], 'shopwareMerchantId' => Uuid::randomHex()]]; + yield 'Missing exp' => [['paypalMerchantId' => 'MERCHANT_ID', 'iat' => new \DateTimeImmutable(), 'scope' => ['cart', 'checkout'], 'shopwareMerchantId' => Uuid::randomHex()]]; + + yield 'Empty salesChannelId' => [['paypalMerchantId' => 'MERCHANT_ID', 'iat' => new \DateTimeImmutable(), 'exp' => new \DateTimeImmutable('+1 hour'), 'scope' => []]]; + } + + public function testResolveWithWrongAgentScopeInRoute(): void + { + $jwt = self::encodeJWT( + 'MERCHANT_ID', + new \DateTimeImmutable(), + new \DateTimeImmutable('+1 hour'), + ['cart', 'checkout'], + 'SALES_CHANNEL_ID' + ); + + $request = new Request(); + $request->headers->set('Authorization', $jwt); + $request->attributes->set(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, [AgentRouteScope::ID]); + $request->attributes->set(AgentRouteScope::ATTRIBUTE_PAYPAL_AGENT_SCOPE, ['wrong-scope']); + + $export = new ProductExportEntity(); + $export->setId(Uuid::randomHex()); + $export->setProductStreamId(Uuid::randomHex()); + $export->setStorefrontSalesChannelId(Uuid::randomHex()); + $entityRepository = $this->createMock(EntityRepository::class); + $entityRepository + ->expects($this->once()) + ->method('search') + ->willReturn(self::createSearchResult($export)); + + $resolver = new AgentRequestContextResolver( + $this->createMock(DataValidator::class), + $entityRepository, + new JWTDecoder(), + new RouteScopeRegistry([new AgentRouteScope()]), + $this->createMock(SalesChannelContextService::class), + ); + + $this->expectExceptionObject(AgentException::unauthorized('Invalid JWT token')); + + $resolver->resolve($request); + } + + public function testResolveWithWrongAgentScopeInRequest(): void + { + $streamId = Uuid::randomHex(); + $iat = new \DateTimeImmutable(); + $exp = new \DateTimeImmutable('+1 hour'); + + $jwt = self::encodeJWT( + 'MERCHANT_ID', + $iat, + $exp, + ['these', 'are', 'wrong', 'scopes'], + 'SALES_CHANNEL_ID' + ); + + $request = Request::create('/CART-12345678912345678912345678912345/foo-bar'); + $request->headers->set('Authorization', $jwt); + $request->attributes->set(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, [AgentRouteScope::ID]); + $request->attributes->set(AgentRouteScope::ATTRIBUTE_PAYPAL_AGENT_SCOPE, ['wrong-scope']); + + $expectedSource = new AgentSource( + 'MERCHANT_ID', + $iat, + $exp, + ['these', 'are', 'wrong', 'scopes'], + 'SALES_CHANNEL_ID', + ); + + $expectedContext = new Context($expectedSource); + + $productExport = new ProductExportEntity(); + $productExport->setId(Uuid::randomHex()); + $productExport->setProductStreamId($streamId); + $productExport->setSalesChannelId('SALES_CHANNEL_ID'); + $productExport->setStorefrontSalesChannelId('SALES_CHANNEL_ID'); + + $productExportResult = new EntitySearchResult( + ProductExportDefinition::ENTITY_NAME, + 1, + new ProductExportCollection([$productExport]), + null, + new Criteria(), + $expectedContext + ); + + $repo = $this->createMock(EntityRepository::class); + $repo + ->expects($this->once()) + ->method('search') + ->with(static::isInstanceOf(Criteria::class), $expectedContext) + ->willReturn($productExportResult); + + $contextService = $this->createMock(SalesChannelContextService::class); + $contextService + ->expects($this->once()) + ->method('get') + ->willReturn( + Generator::generateSalesChannelContext($expectedContext) + ); + + $resolver = new AgentRequestContextResolver( + $this->createMock(DataValidator::class), + $repo, + new JWTDecoder(), + new RouteScopeRegistry([new AgentRouteScope()]), + $contextService, + ); + + $this->expectExceptionObject(AgentException::unauthorized('Invalid JWT token')); + + $resolver->resolve($request); + } + + public function testResolve(): void + { + $iat = new \DateTimeImmutable(); + $exp = new \DateTimeImmutable('+1 hour'); + + $jwt = self::encodeJWT('MERCHANT_ID', $iat, $exp, ['cart', 'checkout'], 'SALES_CHANNEL_ID'); + + $request = new Request(); + $request->headers->set('Authorization', $jwt); + $request->attributes->set(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, [AgentRouteScope::ID]); + $request->attributes->set(AgentRouteScope::ATTRIBUTE_PAYPAL_AGENT_SCOPE, ['cart', 'checkout']); + + $export = new ProductExportEntity(); + $export->setId(Uuid::randomHex()); + $export->setProductStreamId(Uuid::randomHex()); + $export->setStorefrontSalesChannelId(Uuid::randomHex()); + $entityRepository = $this->createMock(EntityRepository::class); + $entityRepository + ->expects($this->once()) + ->method('search') + ->willReturn(self::createSearchResult($export)); + + $salesChannelContext = Generator::generateSalesChannelContext(); + + $salesChannelMock = $this->createMock(SalesChannelContextService::class); + $salesChannelMock + ->expects($this->once()) + ->method('get') + ->willReturn($salesChannelContext); + + $resolver = new AgentRequestContextResolver( + $this->createMock(DataValidator::class), + $entityRepository, + new JWTDecoder(), + new RouteScopeRegistry([new AgentRouteScope()]), + $salesChannelMock, + ); + + $resolver->resolve($request); + + static::assertTrue($request->attributes->has(PlatformRequest::ATTRIBUTE_SALES_CHANNEL_CONTEXT_OBJECT)); + + $resultedSalesChannelContext = $request->attributes->get(PlatformRequest::ATTRIBUTE_SALES_CHANNEL_CONTEXT_OBJECT); + + static::assertInstanceOf(SalesChannelContext::class, $resultedSalesChannelContext); + static::assertSame($salesChannelContext, $resultedSalesChannelContext); + } + + /** + * @param non-empty-string|null $paypalMerchantId + * @param list|null $scopes + * @param non-empty-string|null $salesChannelId + */ + private static function encodeJWT(?string $paypalMerchantId = null, ?\DateTimeImmutable $iat = null, ?\DateTimeImmutable $exp = null, ?array $scopes = null, ?string $salesChannelId = null): string + { + $configuration = Configuration::forAsymmetricSigner( + new Sha256(), + InMemory::plainText(self::JWT_PRIVATE), + InMemory::plainText(self::JWT_PUBLIC), + ); + + $builder = $configuration->builder(); + $builder = $builder->issuedBy(AgentRequestContextResolver::JWT_EXPECTED_ISSUER); + + if ($paypalMerchantId !== null) { + $builder = $builder->withClaim('external_id', [\sprintf('PayPal:%s', $paypalMerchantId)]); + } + + if ($scopes !== null) { + $builder = $builder->withClaim('scope', $scopes); + } + + if ($iat !== null) { + $builder = $builder->issuedAt($iat); + } + + if ($exp !== null) { + $builder = $builder->expiresAt($exp); + } + + if ($salesChannelId !== null) { + $builder = $builder->relatedTo($salesChannelId); + } + + return $builder + ->getToken($configuration->signer(), $configuration->signingKey()) + ->toString(); + } + + private static function createSearchResult(?ProductExportEntity $productExport): EntitySearchResult + { + return new EntitySearchResult( + ProductExportDefinition::ENTITY_NAME, + $productExport ? 0 : 1, + new ProductExportCollection($productExport ? [$productExport] : []), + null, + new Criteria(), + Context::createDefaultContext(), + ); + } + + /** + * @return non-empty-string + */ + private function createSalesChannelWithExport(): string + { + $salesChannelId = Uuid::randomHex(); + $salesChannelDomainId = Uuid::randomHex(); + + $this->createSalesChannel([ + 'id' => $salesChannelId, + 'typeId' => SwagPayPal::SALES_CHANNEL_TYPE_AGENT_COMMERCE, + 'active' => true, + 'domains' => [ + [ + 'id' => $salesChannelDomainId, + 'languageId' => Defaults::LANGUAGE_SYSTEM, + 'currencyId' => Defaults::CURRENCY, + 'snippetSetId' => $this->getSnippetSetIdForLocale('en-GB'), + 'url' => 'http://hatoken.de', + ], + ], + 'productExports' => [ + [ + 'productStream' => [ + 'name' => 'Test Product Stream', + 'active' => true, + 'type' => '{}', + ], + 'generateByCronjob' => false, + 'salesChannelDomainId' => $salesChannelDomainId, + 'salesChannelId' => $salesChannelId, + 'storefrontSalesChannelId' => $salesChannelId, + 'currencyId' => $this->getCurrencyIdByIso(), + 'fileName' => 'foo', + 'accessKey' => '123', + 'encoding' => 'UTF-8', + 'fileFormat' => 'csv', + 'interval' => 0, + ], + ], + ]); + + return $salesChannelId; + } +} diff --git a/tests/AgentCommerce/Routing/AgentRouteScopeTest.php b/tests/AgentCommerce/Routing/AgentRouteScopeTest.php new file mode 100644 index 000000000..9a8958eb1 --- /dev/null +++ b/tests/AgentCommerce/Routing/AgentRouteScopeTest.php @@ -0,0 +1,119 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\Tests\AgentCommerce\Routing; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Shopware\Core\Framework\Api\Context\SystemSource; +use Shopware\Core\Framework\Context; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\PlatformRequest; +use Shopware\Core\System\SalesChannel\SalesChannelContext; +use Swag\PayPal\AgentCommerce\Routing\AgentRouteScope; +use Swag\PayPal\AgentCommerce\Routing\AgentSource; +use Symfony\Component\HttpFoundation\Request; + +/** + * @internal + */ +#[Package('checkout')] +#[CoversClass(AgentRouteScope::class)] +class AgentRouteScopeTest extends TestCase +{ + public function testIsAllowedWithMissingAuthorizationHeader(): void + { + $scope = new AgentRouteScope(); + + $request = new Request(); + $request->headers->set('Content-Type', 'application/json'); + + static::assertFalse($scope->isAllowed($request)); + } + + public function testIsAllowedWithMissingContentTypeHeader(): void + { + $scope = new AgentRouteScope(); + + $request = new Request(); + $request->headers->set('Authorization', 'ey.jwt.token'); + + static::assertFalse($scope->isAllowed($request)); + } + + public function testIsAllowedWithWrongContentType(): void + { + $scope = new AgentRouteScope(); + + $request = new Request(); + $request->headers->set('Authorization', 'ey.jwt.token'); + $request->headers->set('Content-Type', 'text/plain'); + + static::assertFalse($scope->isAllowed($request)); + } + + public function testIsAllowedWithNoContext(): void + { + $scope = new AgentRouteScope(); + + $request = new Request(); + $request->headers->set('Authorization', 'ey.jwt.token'); + $request->headers->set('Content-Type', 'application/json'); + + static::assertFalse($scope->isAllowed($request)); + } + + public function testIsAllowedWithWrongContextObject(): void + { + $scope = new AgentRouteScope(); + + $request = new Request(); + $request->headers->set('Authorization', 'ey.jwt.token'); + $request->headers->set('Content-Type', 'application/json'); + $request->attributes->set(PlatformRequest::ATTRIBUTE_CONTEXT_OBJECT, new \stdClass()); + + static::assertFalse($scope->isAllowed($request)); + } + + public function testIsAllowedWithWrongContextSource(): void + { + $scope = new AgentRouteScope(); + + $context = Context::createDefaultContext(new SystemSource()); + + $request = new Request(); + $request->headers->set('Authorization', 'ey.jwt.token'); + $request->headers->set('Content-Type', 'application/json'); + $request->attributes->set(PlatformRequest::ATTRIBUTE_CONTEXT_OBJECT, $context); + + static::assertFalse($scope->isAllowed($request)); + } + + public function testIsAllowed(): void + { + $scope = new AgentRouteScope(); + $source = new AgentSource('MERCHANT_ID', new \DateTimeImmutable(), new \DateTimeImmutable('+1 hour'), ['cart'], 'sales-channel-id', 'debug-id'); + + $salesChannelContext = $this->createMock(SalesChannelContext::class); + $salesChannelContext + ->expects($this->once()) + ->method('getContext') + ->willReturn(Context::createDefaultContext($source)); + + $request = new Request(); + $request->headers->set('Authorization', 'ey.jwt.token'); + $request->headers->set('Content-Type', 'application/json'); + $request->attributes->set(PlatformRequest::ATTRIBUTE_SALES_CHANNEL_CONTEXT_OBJECT, $salesChannelContext); + + static::assertTrue($scope->isAllowed($request)); + } + + public function testGetId(): void + { + static::assertSame('paypal-agent', (new AgentRouteScope())->getId()); + } +} diff --git a/tests/AgentCommerce/Routing/AgentSourceTest.php b/tests/AgentCommerce/Routing/AgentSourceTest.php new file mode 100644 index 000000000..baaa99131 --- /dev/null +++ b/tests/AgentCommerce/Routing/AgentSourceTest.php @@ -0,0 +1,62 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\Tests\AgentCommerce\Routing; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Shopware\Core\Framework\Log\Package; +use Swag\PayPal\AgentCommerce\Routing\AgentRouteScope; +use Swag\PayPal\AgentCommerce\Routing\AgentSource; + +/** + * @internal + */ +#[Package('checkout')] +#[CoversClass(AgentSource::class)] +class AgentSourceTest extends TestCase +{ + public function testConstruct(): void + { + $merchantId = 'test-merchant-id'; + $issuedAt = new \DateTimeImmutable(); + $expiresAt = new \DateTimeImmutable('+1 hour'); + $scope = [AgentSource::SCOPE_CART, AgentSource::SCOPE_CHECKOUT]; + $salesChannelId = 'sales-channel-id'; + $debugId = 'test-debug-id'; + + $source = new AgentSource($merchantId, $issuedAt, $expiresAt, $scope, $salesChannelId, $debugId); + + static::assertSame($merchantId, $source->merchantId); + static::assertSame($issuedAt, $source->issuedAt); + static::assertSame($expiresAt, $source->expiresAt); + static::assertSame($scope, $source->scope); + static::assertSame($salesChannelId, $source->salesChannelId); + static::assertSame($debugId, $source->debugId); + + static::assertSame(AgentRouteScope::ID, $source->type); + + static::assertTrue($source->hasScope(AgentSource::SCOPE_CART)); + static::assertTrue($source->hasScope(AgentSource::SCOPE_CHECKOUT)); + static::assertFalse($source->hasScope('non-existent-scope')); + + static::assertFalse($source->isExpired()); + } + + public function testExpiredSource(): void + { + $merchantId = 'test-merchant-id'; + $issuedAt = new \DateTimeImmutable('-2 hours'); + $expiresAt = new \DateTimeImmutable('-1 hour'); + $scope = [AgentSource::SCOPE_CART]; + $debugId = 'test-debug-id'; + + $source = new AgentSource($merchantId, $issuedAt, $expiresAt, $scope, $debugId); + + static::assertTrue($source->isExpired()); + } +} diff --git a/tests/AgentCommerce/SalesChannel/CheckoutRouteTest.php b/tests/AgentCommerce/SalesChannel/CheckoutRouteTest.php new file mode 100644 index 000000000..797e49391 --- /dev/null +++ b/tests/AgentCommerce/SalesChannel/CheckoutRouteTest.php @@ -0,0 +1,264 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\Tests\AgentCommerce\SalesChannel; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Shopware\Core\Checkout\Cart\Cart; +use Shopware\Core\Checkout\Cart\SalesChannel\AbstractCartOrderRoute; +use Shopware\Core\Checkout\Cart\SalesChannel\CartOrderRouteResponse; +use Shopware\Core\Checkout\Cart\SalesChannel\CartService; +use Shopware\Core\Checkout\Order\Aggregate\OrderTransaction\OrderTransactionCollection; +use Shopware\Core\Checkout\Order\Aggregate\OrderTransaction\OrderTransactionEntity; +use Shopware\Core\Checkout\Order\OrderEntity; +use Shopware\Core\Checkout\Payment\Cart\PaymentTransactionStruct; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Test\Generator; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\PayPalCart; +use Shopware\PayPalSDK\Struct\V2\Order; +use Swag\PayPal\AgentCommerce\Exception\AgentException; +use Swag\PayPal\AgentCommerce\SalesChannel\CheckoutRoute; +use Swag\PayPal\AgentCommerce\Util\PayPalCartTransformer; +use Swag\PayPal\Checkout\Payment\PayPalPaymentHandler; +use Swag\PayPal\RestApi\V2\Resource\OrderResource; +use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\Request; + +/** + * @internal + */ +#[CoversClass(CheckoutRoute::class)] +#[Package('checkout')] +class CheckoutRouteTest extends TestCase +{ + public function testCheckoutWithInvalidCartToken(): void + { + $route = new CheckoutRoute( + $this->createMock(AbstractCartOrderRoute::class), + $this->createMock(CartService::class), + $this->createMock(OrderResource::class), + $this->createMock(PayPalPaymentHandler::class), + $this->createMock(PayPalCartTransformer::class) + ); + + $this->expectExceptionObject(AgentException::invalidCartId()); + + $route->checkout('invalid-token', new Request(), Generator::generateSalesChannelContext()); + } + + public function testCheckoutWithEmptyCart(): void + { + $token = 'CART-TOKEN'; + + $cartService = $this->createMock(CartService::class); + $cartService + ->expects($this->once()) + ->method('getCart') + ->with('TOKEN') + ->willReturn(new Cart('TOKEN')); + + $route = new CheckoutRoute( + $this->createMock(AbstractCartOrderRoute::class), + $cartService, + $this->createMock(OrderResource::class), + $this->createMock(PayPalPaymentHandler::class), + $this->createMock(PayPalCartTransformer::class) + ); + + $this->expectExceptionObject(AgentException::cartNotFound($token)); + + $route->checkout($token, new Request(), Generator::generateSalesChannelContext()); + } + + public function testCheckoutWithoutTransaction(): void + { + $token = 'CART-TOKEN'; + + $cartService = $this->createMock(CartService::class); + $cartService + ->expects($this->once()) + ->method('getCart') + ->with('TOKEN') + ->willReturn(Generator::createCart()); + + $order = new OrderEntity(); + $order->setTransactions(new OrderTransactionCollection([])); + + $orderResponse = new CartOrderRouteResponse($order); + + $orderRoute = $this->createMock(AbstractCartOrderRoute::class); + $orderRoute + ->expects($this->once()) + ->method('order') + ->willReturn($orderResponse); + + $route = new CheckoutRoute( + $orderRoute, + $cartService, + $this->createMock(OrderResource::class), + $this->createMock(PayPalPaymentHandler::class), + $this->createMock(PayPalCartTransformer::class) + ); + + $this->expectExceptionObject(AgentException::orderSystemError()); + + $route->checkout($token, new Request(), Generator::generateSalesChannelContext()); + } + + public function testCheckout(): void + { + $token = 'CART-TOKEN'; + + $context = Generator::generateSalesChannelContext(); + $cart = Generator::createCart(); + + $request = new Request(content: \json_encode(['payment_method' => ['token' => 'PAYPAL-ORDER-ID']], \JSON_THROW_ON_ERROR)); + + $cartService = $this->createMock(CartService::class); + $cartService + ->expects($this->once()) + ->method('getCart') + ->with('TOKEN') + ->willReturn($cart); + + $transaction = new OrderTransactionEntity(); + $transaction->setId('primary-order-transaction-id'); + + $order = new OrderEntity(); + $order->setTransactions(new OrderTransactionCollection([$transaction])); + + $orderResponse = new CartOrderRouteResponse($order); + + $orderRoute = $this->createMock(AbstractCartOrderRoute::class); + $orderRoute + ->expects($this->once()) + ->method('order') + ->willReturn($orderResponse); + + $payPalOrder = new Order(); + $payPalOrder->setId('PAYPAL-ORDER-ID'); + + $orderResource = $this->createMock(OrderResource::class); + $orderResource + ->expects($this->once()) + ->method('get') + ->with('PAYPAL-ORDER-ID', $context->getSalesChannelId()) + ->willReturn($payPalOrder); + + $payPalCart = new PayPalCart(); + $payPalCart->setId('PAYPAL-ORDER-ID'); + + $transformer = $this->createMock(PayPalCartTransformer::class); + $transformer + ->expects($this->once()) + ->method('convertToPayPalCart') + ->with($cart, $context) + ->willReturn($payPalCart); + + $paymentHandler = $this->createMock(PayPalPaymentHandler::class); + $paymentHandler + ->expects($this->once()) + ->method('pay') + ->with($request, static::equalTo(new PaymentTransactionStruct('primary-order-transaction-id')), $context->getContext(), null) + ->willReturn(null); + + $route = new CheckoutRoute( + $orderRoute, + $cartService, + $orderResource, + $paymentHandler, + $transformer + ); + + $response = $route->checkout($token, $request, $context); + $cart = $response->getCart(); + + static::assertSame(PayPalCart::STATUS__COMPLETE, $cart->getStatus()); + + static::assertNotNull($cart->getPaymentMethod()); + static::assertSame('PAYPAL-ORDER-ID', $cart->getPaymentMethod()->getToken()); + } + + public function testCheckoutWithRedirect(): void + { + $token = 'CART-TOKEN'; + + $context = Generator::generateSalesChannelContext(); + $cart = Generator::createCart(); + + $request = new Request(content: \json_encode(['payment_method' => ['token' => 'PAYPAL-ORDER-ID']], \JSON_THROW_ON_ERROR)); + + $cartService = $this->createMock(CartService::class); + $cartService + ->expects($this->once()) + ->method('getCart') + ->with('TOKEN') + ->willReturn($cart); + + $transaction = new OrderTransactionEntity(); + $transaction->setId('primary-order-transaction-id'); + + $order = new OrderEntity(); + $order->setTransactions(new OrderTransactionCollection([$transaction])); + + $orderResponse = new CartOrderRouteResponse($order); + + $orderRoute = $this->createMock(AbstractCartOrderRoute::class); + $orderRoute + ->expects($this->once()) + ->method('order') + ->willReturn($orderResponse); + + $payPalOrder = new Order(); + $payPalOrder->setId('PAYPAL-ORDER-ID'); + + $orderResource = $this->createMock(OrderResource::class); + $orderResource + ->expects($this->once()) + ->method('get') + ->with('PAYPAL-ORDER-ID', $context->getSalesChannelId()) + ->willReturn($payPalOrder); + + $payPalCart = new PayPalCart(); + $payPalCart->setId('PAYPAL-ORDER-ID'); + + $transformer = $this->createMock(PayPalCartTransformer::class); + $transformer + ->expects($this->once()) + ->method('convertToPayPalCart') + ->with($cart, $context) + ->willReturn($payPalCart); + + $redirect = new RedirectResponse('https://example.com/redirect-url'); + + $paymentHandler = $this->createMock(PayPalPaymentHandler::class); + $paymentHandler + ->expects($this->once()) + ->method('pay') + ->with($request, static::equalTo(new PaymentTransactionStruct('primary-order-transaction-id')), $context->getContext(), null) + ->willReturn($redirect); + + $route = new CheckoutRoute( + $orderRoute, + $cartService, + $orderResource, + $paymentHandler, + $transformer + ); + + $response = $route->checkout($token, $request, $context); + $cart = $response->getCart(); + + static::assertSame(PayPalCart::STATUS__INCOMPLETE, $cart->getStatus()); + static::assertSame(PayPalCart::VALIDATION_STATUS__INVALID, $cart->getValidationStatus()); + + static::assertNotNull($cart->getPaymentMethod()); + static::assertSame('PAYPAL-ORDER-ID', $cart->getPaymentMethod()->getToken()); + static::assertSame('https://example.com/redirect-url', $cart->getPaymentMethod()->getApprovalUrl()); + } +} diff --git a/tests/AgentCommerce/SalesChannel/CreateCartRouteTest.php b/tests/AgentCommerce/SalesChannel/CreateCartRouteTest.php new file mode 100644 index 000000000..f545b60c5 --- /dev/null +++ b/tests/AgentCommerce/SalesChannel/CreateCartRouteTest.php @@ -0,0 +1,166 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\Test\AgentCommerce\SalesChannel; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Shopware\Core\Checkout\Cart\Cart; +use Shopware\Core\Checkout\Cart\SalesChannel\CartService; +use Shopware\Core\Checkout\Customer\SalesChannel\AbstractRegisterRoute; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Uuid\Uuid; +use Shopware\Core\System\SalesChannel\Context\SalesChannelContextService; +use Shopware\Core\System\SalesChannel\SalesChannelContext; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\PayPalCart; +use Shopware\PayPalSDK\Struct\V2\Order; +use Swag\PayPal\AgentCommerce\SalesChannel\CreateCartRoute; +use Swag\PayPal\AgentCommerce\Util\PayPalCartTransformer; +use Swag\PayPal\AgentCommerce\Util\ShopwareCartTransformer; +use Swag\PayPal\OrdersApi\Builder\AbstractOrderBuilder; +use Swag\PayPal\RestApi\V2\Resource\OrderResource; +use Symfony\Component\HttpFoundation\Request; + +/** + * @internal + */ +#[CoversClass(CreateCartRoute::class)] +#[Package('checkout')] +class CreateCartRouteTest extends TestCase +{ + private SalesChannelContextService&MockObject $contextService; + + private CartService&MockObject $cartService; + + private PayPalCartTransformer&MockObject $payPalCartTransformer; + + private ShopwareCartTransformer&MockObject $shopwareCartTransformer; + + private AbstractRegisterRoute&MockObject $registerRoute; + + private AbstractOrderBuilder&MockObject $orderBuilder; + + private OrderResource&MockObject $orderResource; + + private CreateCartRoute $createCartRoute; + + protected function setUp(): void + { + $this->contextService = $this->createMock(SalesChannelContextService::class); + $this->cartService = $this->createMock(CartService::class); + $this->payPalCartTransformer = $this->createMock(PayPalCartTransformer::class); + $this->shopwareCartTransformer = $this->createMock(ShopwareCartTransformer::class); + $this->registerRoute = $this->createMock(AbstractRegisterRoute::class); + $this->orderBuilder = $this->createMock(AbstractOrderBuilder::class); + $this->orderResource = $this->createMock(OrderResource::class); + + $this->createCartRoute = new CreateCartRoute( + $this->contextService, + $this->cartService, + $this->payPalCartTransformer, + $this->shopwareCartTransformer, + $this->registerRoute, + $this->orderBuilder, + $this->orderResource, + ); + } + + public function testCreateCartWithCreateAndLoginCustomer(): void + { + $cartData = array_merge(self::createItems(), self::createCustomer()); + + $salesChannelContext = $this->createMock(SalesChannelContext::class); + $salesChannelContext + ->method('getCustomer') + ->willReturn(null); + + $this->shopwareCartTransformer + ->expects($this->once()) + ->method('extractCustomerData') + ->willReturn(['valid-data']); + + $this->registerRoute + ->expects($this->once()) + ->method('register'); + + $this->contextService + ->method('get') + ->willReturn($salesChannelContext); + + $cart = new Cart(''); + + $this->cartService + ->expects($this->once()) + ->method('createNew') + ->willReturn($cart); + $this->cartService + ->expects($this->once()) + ->method('add') + ->willReturn($cart); + + $this->shopwareCartTransformer + ->expects($this->once()) + ->method('getLineItems') + ->willReturn([]); + + $order = new Order(); + $order->setId('some-order-id'); + + $this->orderBuilder + ->expects($this->once()) + ->method('getOrderFromCart') + ->willReturn($order); + + $this->orderResource + ->expects($this->once()) + ->method('create') + ->willReturn($order); + + $payPalCart = new PayPalCart(); + $payPalCart->setValidationStatus(PayPalCart::VALIDATION_STATUS__VALID); + + $this->payPalCartTransformer + ->expects($this->once()) + ->method('convertToPayPalCart') + ->willReturn($payPalCart); + + $content = json_encode($cartData); + static::assertIsString($content); + + $response = $this->createCartRoute->createCart(new Request(content: $content), $salesChannelContext); + + static::assertSame(PayPalCart::STATUS__CREATED, $response->getCart()->getStatus()); + static::assertSame(PayPalCart::VALIDATION_STATUS__VALID, $response->getCart()->getValidationStatus()); + static::assertSame('some-order-id', $response->getCart()->getPaymentMethod()?->getToken()); + } + + private static function createItems(): array + { + return ['items' => [['variant_id' => Uuid::randomHex(), 'quantity' => 1]]]; + } + + private static function createCustomer(): array + { + return [ + 'customer' => [ + 'email_address' => 'email@example.com', + 'name' => ['given_name' => 'Mustermann', 'surname' => 'Max'], + ], + 'shipping_address' => [ + 'address_line_1' => '123 Main Street', + 'admin_area_2' => 'City', + 'country_code' => 'DE', + ], + 'billing_address' => [ + 'address_line_1' => '456 Other Street', + 'admin_area_2' => 'City 2', + 'country_code' => 'DE', + ], + ]; + } +} diff --git a/tests/AgentCommerce/SalesChannel/GetCartRouteTest.php b/tests/AgentCommerce/SalesChannel/GetCartRouteTest.php new file mode 100644 index 000000000..e2a3b9be9 --- /dev/null +++ b/tests/AgentCommerce/SalesChannel/GetCartRouteTest.php @@ -0,0 +1,119 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\Test\AgentCommerce\SalesChannel; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Shopware\Core\Checkout\Cart\Cart; +use Shopware\Core\Checkout\Cart\LineItem\LineItem; +use Shopware\Core\Checkout\Cart\SalesChannel\CartService; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Uuid\Uuid; +use Shopware\Core\System\SalesChannel\SalesChannelContext; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\PayPalCart; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\ValidationIssue; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\ValidationIssueCollection; +use Swag\PayPal\AgentCommerce\Exception\AgentException; +use Swag\PayPal\AgentCommerce\SalesChannel\GetCartRoute; +use Swag\PayPal\AgentCommerce\Util\PayPalCartTransformer; + +/** + * @internal + */ +#[CoversClass(GetCartRoute::class)] +#[Package('checkout')] +class GetCartRouteTest extends TestCase +{ + public function testInvalidCartToken(): void + { + $this->expectException(AgentException::class); + $this->expectExceptionMessage('Cart ID format is invalid. Expected format: CART-[a-zA-Z0-9]{32}'); + + $getCartRoute = new GetCartRoute( + $this->createMock(CartService::class), + $this->createMock(PayPalCartTransformer::class) + ); + + $getCartRoute->getCart('invalid-token', $this->createMock(SalesChannelContext::class)); + } + + public function testEmptyCart(): void + { + $token = 'CART-11111111111111111111111111111111'; + + $this->expectException(AgentException::class); + $this->expectExceptionMessage('Cart with ID \'' . $token . '\' does not exist'); + + $cartService = $this->createMock(CartService::class); + $cartService + ->method('getCart') + ->willReturn(new Cart('token')); + + $getCartRoute = new GetCartRoute( + $cartService, + $this->createMock(PayPalCartTransformer::class) + ); + + $getCartRoute->getCart($token, $this->createMock(SalesChannelContext::class)); + } + + public function testCartWithValidationIssues(): void + { + $token = 'CART-11111111111111111111111111111111'; + + $cart = new Cart('token'); + $cart->add(new LineItem(Uuid::randomHex(), 'test')); + + $cartService = $this->createMock(CartService::class); + $cartService + ->method('getCart') + ->willReturn($cart); + + $payPalCart = new PayPalCart(); + $payPalCart->setValidationStatus(PayPalCart::VALIDATION_STATUS__INVALID); + $payPalCart->setValidationIssues(new ValidationIssueCollection([new ValidationIssue()])); + + $cartTransformer = $this->createMock(PayPalCartTransformer::class); + $cartTransformer + ->method('convertToPayPalCart') + ->willReturn($payPalCart); + + $getCartRoute = new GetCartRoute($cartService, $cartTransformer); + + $response = $getCartRoute->getCart($token, $this->createMock(SalesChannelContext::class)); + + static::assertSame(PayPalCart::STATUS__INCOMPLETE, $response->getCart()->getStatus()); + } + + public function testValidCart(): void + { + $token = 'CART-11111111111111111111111111111111'; + + $cart = new Cart('token'); + $cart->add(new LineItem(Uuid::randomHex(), 'test')); + + $cartService = $this->createMock(CartService::class); + $cartService + ->method('getCart') + ->willReturn($cart); + + $payPalCart = new PayPalCart(); + $payPalCart->setValidationStatus(PayPalCart::VALIDATION_STATUS__VALID); + + $cartTransformer = $this->createMock(PayPalCartTransformer::class); + $cartTransformer + ->method('convertToPayPalCart') + ->willReturn($payPalCart); + + $getCartRoute = new GetCartRoute($cartService, $cartTransformer); + + $response = $getCartRoute->getCart($token, $this->createMock(SalesChannelContext::class)); + + static::assertSame(PayPalCart::STATUS__READY, $response->getCart()->getStatus()); + } +} diff --git a/tests/AgentCommerce/SalesChannel/Response/AgentCartResponseTest.php b/tests/AgentCommerce/SalesChannel/Response/AgentCartResponseTest.php new file mode 100644 index 000000000..ba3112ab6 --- /dev/null +++ b/tests/AgentCommerce/SalesChannel/Response/AgentCartResponseTest.php @@ -0,0 +1,32 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\Tests\AgentCommerce\SalesChannel\Response; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Shopware\Core\Framework\Log\Package; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\PayPalCart; +use Swag\PayPal\AgentCommerce\SalesChannel\Response\AgentCartResponse; + +/** + * @internal + */ +#[Package('checkout')] +#[CoversClass(AgentCartResponse::class)] +class AgentCartResponseTest extends TestCase +{ + public function testConstruct(): void + { + $cart = new PayPalCart(); + $cart->setId('test-token'); + + $response = new AgentCartResponse($cart); + + static::assertSame(['id' => 'test-token'], $response->getObject()->all()); + } +} diff --git a/tests/AgentCommerce/SalesChannel/Response/AgentResponseExceptionSubscriberTest.php b/tests/AgentCommerce/SalesChannel/Response/AgentResponseExceptionSubscriberTest.php new file mode 100644 index 000000000..caa9aa5ef --- /dev/null +++ b/tests/AgentCommerce/SalesChannel/Response/AgentResponseExceptionSubscriberTest.php @@ -0,0 +1,208 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\Tests\AgentCommerce\SalesChannel\Response; + +use Monolog\Logger; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Shopware\Core\Framework\Api\Context\SystemSource; +use Shopware\Core\Framework\Context; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Routing\RouteScopeRegistry; +use Shopware\Core\PlatformRequest; +use Shopware\Core\Test\Integration\PaymentHandler\TestPaymentHandler; +use Shopware\Core\Test\Stub\Symfony\StubKernel; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\AgentError; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\AgentErrorDetail; +use Shopware\PayPalSDK\Struct\Struct; +use Swag\PayPal\AgentCommerce\Exception\AgentException; +use Swag\PayPal\AgentCommerce\Routing\AgentRouteScope; +use Swag\PayPal\AgentCommerce\Routing\AgentSource; +use Swag\PayPal\AgentCommerce\SalesChannel\Response\AgentResponseExceptionSubscriber; +use Swag\PayPal\Checkout\CheckoutException; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Event\ExceptionEvent; +use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\HttpKernel\KernelEvents; + +/** + * @internal + */ +#[Package('checkout')] +#[CoversClass(AgentResponseExceptionSubscriber::class)] +class AgentResponseExceptionSubscriberTest extends TestCase +{ + public function testSubscribedEvents(): void + { + static::assertSame( + [ + KernelEvents::EXCEPTION => [ + ['onKernelException', 0], + ], + ], + AgentResponseExceptionSubscriber::getSubscribedEvents() + ); + } + + public function testOnKernelExceptionWithoutContext(): void + { + $request = new Request(); + $request->attributes->set(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, [AgentRouteScope::ID]); + $event = self::createEvent($request, new \Exception('Test exception')); + + $subscriber = new AgentResponseExceptionSubscriber(new Logger('test'), new RouteScopeRegistry([new AgentRouteScope()])); + $subscriber->onKernelException($event); + + static::assertNotNull($event->getResponse()); + + $response = $event->getResponse(); + static::assertNotFalse($response->getContent()); + + $content = \json_decode($response->getContent(), true, flags: \JSON_THROW_ON_ERROR); + $error = Struct::from(AgentError::class, $content); + + static::assertNull($error->getDebugId()); + } + + public function testOnKernelExceptionWithNonContextObject(): void + { + $request = new Request(); + $request->attributes->set(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, [AgentRouteScope::ID]); + $request->attributes->set(PlatformRequest::ATTRIBUTE_CONTEXT_OBJECT, new \stdClass()); + $event = self::createEvent($request, new \Exception('Test exception')); + + $subscriber = new AgentResponseExceptionSubscriber(new Logger('test'), new RouteScopeRegistry([new AgentRouteScope()])); + $subscriber->onKernelException($event); + + static::assertNotNull($event->getResponse()); + + $response = $event->getResponse(); + static::assertNotFalse($response->getContent()); + + $content = \json_decode($response->getContent(), true, flags: \JSON_THROW_ON_ERROR); + $error = Struct::from(AgentError::class, $content); + + static::assertNull($error->getDebugId()); + } + + public function testOnKernelExceptionWithNonPayPalAgentSource(): void + { + $request = new Request(); + $context = Context::createDefaultContext(new SystemSource()); + $request->attributes->set(PlatformRequest::ATTRIBUTE_CONTEXT_OBJECT, $context); + $event = self::createEvent($request, new \Exception('Test exception')); + + $subscriber = new AgentResponseExceptionSubscriber(new Logger('test'), new RouteScopeRegistry([new AgentRouteScope()])); + $subscriber->onKernelException($event); + + static::assertNull($event->getResponse()); + } + + public function testOnKernelExceptionPayPalAgentException(): void + { + $source = new AgentSource('MERCHANT_ID', new \DateTimeImmutable(), new \DateTimeImmutable('+1 hour'), ['cart'], 'sales-channel-id', 'debug-id'); + + $request = new Request(); + $context = Context::createDefaultContext($source); + $request->attributes->set(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, [AgentRouteScope::ID]); + $request->attributes->set(PlatformRequest::ATTRIBUTE_CONTEXT_OBJECT, $context); + $event = self::createEvent($request, AgentException::requiredFieldsMissing('foo', 'bar')); + + $subscriber = new AgentResponseExceptionSubscriber(new Logger('test'), new RouteScopeRegistry([new AgentRouteScope()])); + $subscriber->onKernelException($event); + + static::assertNotNull($event->getResponse()); + + $response = $event->getResponse(); + static::assertNotFalse($response->getContent()); + + $content = \json_decode($response->getContent(), true, flags: \JSON_THROW_ON_ERROR); + $error = Struct::from(AgentError::class, $content); + + static::assertInstanceOf(AgentError::class, $error); + static::assertSame('INVALID_REQUEST', $error->getName()); + static::assertSame('Required field \'foo, bar\' is missing', $error->getMessage()); + static::assertSame(400, $error->getCode()); + static::assertSame('debug-id', $error->getDebugId()); + + $details = $error->getDetails(); + + static::assertNotNull($details); + static::assertContainsOnlyInstancesOf(AgentErrorDetail::class, $details); + static::assertCount(2, $details); + + static::assertSame('foo', $details->first()?->getField()); + static::assertSame('MISSING_REQUIRED_FIELD', $details->first()->getIssue()); + static::assertSame('The field \'foo\' is required and cannot be empty', $details->first()->getDescription()); + + static::assertSame('bar', $details->last()?->getField()); + static::assertSame('MISSING_REQUIRED_FIELD', $details->last()->getIssue()); + static::assertSame('The field \'bar\' is required and cannot be empty', $details->last()->getDescription()); + } + + public function testOnKernelExceptionHttpException(): void + { + $source = new AgentSource('MERCHANT_ID', new \DateTimeImmutable(), new \DateTimeImmutable('+1 hour'), ['cart'], 'sales-channel-id', 'debug-id'); + + $request = new Request(); + $context = Context::createDefaultContext($source); + $request->attributes->set(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, [AgentRouteScope::ID]); + $request->attributes->set(PlatformRequest::ATTRIBUTE_CONTEXT_OBJECT, $context); + $event = self::createEvent($request, CheckoutException::preparedOrderRequired(TestPaymentHandler::class)); + + $subscriber = new AgentResponseExceptionSubscriber(new Logger('test'), new RouteScopeRegistry([new AgentRouteScope()])); + $subscriber->onKernelException($event); + + static::assertNotNull($event->getResponse()); + + $response = $event->getResponse(); + static::assertNotFalse($response->getContent()); + + $content = \json_decode($response->getContent(), true, flags: \JSON_THROW_ON_ERROR); + $error = Struct::from(AgentError::class, $content); + + static::assertInstanceOf(AgentError::class, $error); + static::assertSame('PREPARED_ORDER_REQUIRED', $error->getName()); + static::assertSame('PayPal Order ID does not exist in the request. The payment method Shopware\Core\Test\Integration\PaymentHandler\TestPaymentHandler requires a prepared PayPal order.', $error->getMessage()); + static::assertSame(400, $error->getCode()); + static::assertSame('debug-id', $error->getDebugId()); + } + + public function testOnKernelExceptionGenericThrowable(): void + { + $source = new AgentSource('MERCHANT_ID', new \DateTimeImmutable(), new \DateTimeImmutable('+1 hour'), ['cart'], 'sales-channel-id', 'debug-id'); + + $request = new Request(); + $context = Context::createDefaultContext($source); + $request->attributes->set(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, [AgentRouteScope::ID]); + $request->attributes->set(PlatformRequest::ATTRIBUTE_CONTEXT_OBJECT, $context); + $event = self::createEvent($request, new \Exception('Generic error')); + + $subscriber = new AgentResponseExceptionSubscriber(new Logger('test'), new RouteScopeRegistry([new AgentRouteScope()])); + $subscriber->onKernelException($event); + + static::assertNotNull($event->getResponse()); + + $response = $event->getResponse(); + static::assertNotFalse($response->getContent()); + + $content = \json_decode($response->getContent(), true, flags: \JSON_THROW_ON_ERROR); + $error = Struct::from(AgentError::class, $content); + + static::assertInstanceOf(AgentError::class, $error); + static::assertSame('UNKNOWN_ERROR', $error->getName()); + static::assertSame('Generic error', $error->getMessage()); + static::assertSame(500, $error->getCode()); + static::assertSame('debug-id', $error->getDebugId()); + } + + private static function createEvent(Request $request, \Throwable $e): ExceptionEvent + { + return new ExceptionEvent(new StubKernel(), $request, HttpKernelInterface::MAIN_REQUEST, $e); + } +} diff --git a/tests/AgentCommerce/SalesChannel/UpdateCartRouteTest.php b/tests/AgentCommerce/SalesChannel/UpdateCartRouteTest.php new file mode 100644 index 000000000..fee8aa5f9 --- /dev/null +++ b/tests/AgentCommerce/SalesChannel/UpdateCartRouteTest.php @@ -0,0 +1,397 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\Test\AgentCommerce\SalesChannel; + +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Shopware\Core\Checkout\Cart\SalesChannel\CartService; +use Shopware\Core\Checkout\Customer\CustomerEntity; +use Shopware\Core\Checkout\Shipping\ShippingMethodEntity; +use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository; +use Shopware\Core\Framework\Uuid\Uuid; +use Shopware\Core\Framework\Validation\Exception\ConstraintViolationException; +use Shopware\Core\System\SalesChannel\Context\SalesChannelContextService; +use Shopware\Core\System\SalesChannel\ContextTokenResponse; +use Shopware\Core\System\SalesChannel\SalesChannel\ContextSwitchRoute; +use Shopware\Core\System\SalesChannel\SalesChannelContext; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\PayPalCart; +use Swag\PayPal\AgentCommerce\Exception\AgentException; +use Swag\PayPal\AgentCommerce\SalesChannel\CreateCartRoute; +use Swag\PayPal\AgentCommerce\SalesChannel\Response\AgentCartResponse; +use Swag\PayPal\AgentCommerce\SalesChannel\UpdateCartRoute; +use Swag\PayPal\AgentCommerce\Util\ShopwareCartTransformer; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Validator\ConstraintViolationList; + +/** + * @internal + */ +class UpdateCartRouteTest extends TestCase +{ + private UpdateCartRoute $updateCartRoute; + + private MockObject&SalesChannelContextService $contextService; + + private MockObject&ShopwareCartTransformer $shopwareCartTransformer; + + private MockObject&CreateCartRoute $createCartRoute; + + private MockObject&EntityRepository $customerRepository; + + private MockObject&EntityRepository $customerAddressRepository; + + private MockObject&CartService $cartService; + + private MockObject&ContextSwitchRoute $contextSwitchRoute; + + private MockObject&SalesChannelContext $salesChannelContext; + + protected function setUp(): void + { + $this->contextService = $this->createMock(SalesChannelContextService::class); + $this->shopwareCartTransformer = $this->createMock(ShopwareCartTransformer::class); + $this->createCartRoute = $this->createMock(CreateCartRoute::class); + $this->customerRepository = $this->createMock(EntityRepository::class); + $this->customerAddressRepository = $this->createMock(EntityRepository::class); + $this->cartService = $this->createMock(CartService::class); + $this->contextSwitchRoute = $this->createMock(ContextSwitchRoute::class); + $this->salesChannelContext = $this->createMock(SalesChannelContext::class); + + $this->updateCartRoute = new UpdateCartRoute( + $this->contextService, + $this->shopwareCartTransformer, + $this->createCartRoute, + $this->customerRepository, + $this->customerAddressRepository, + $this->cartService, + $this->contextSwitchRoute + ); + } + + public function testInvalidCartToken(): void + { + $this->expectException(AgentException::class); + $this->expectExceptionMessage('Cart ID format is invalid. Expected format: CART-[a-zA-Z0-9]{32}'); + + $this->updateCartRoute->updateCart('invalid-token', new Request(), $this->salesChannelContext); + } + + public function testDeleteCustomer(): void + { + $content = json_encode(self::createItems()); + static::assertIsString($content); + + $customer = new CustomerEntity(); + $customer->setId(Uuid::randomHex()); + + $this->salesChannelContext + ->method('getCustomer') + ->willReturn($customer); + + $this->customerRepository + ->expects($this->once()) + ->method('delete') + ->with([['id' => $customer->getId()]]); + + $this->cartService + ->expects($this->once()) + ->method('deleteCart'); + + $payPalCart = new PayPalCart(); + $payPalCart->setValidationStatus(PayPalCart::VALIDATION_STATUS__VALID); + $createResponse = new AgentCartResponse($payPalCart); + + $this->createCartRoute + ->expects($this->once()) + ->method('createCart') + ->willReturn($createResponse); + + $response = $this->updateCartRoute->updateCart('CART-11111111111111111111111111111111', new Request(content: $content), $this->salesChannelContext); + + static::assertSame(200, $response->getStatusCode()); + static::assertSame(PayPalCart::STATUS__READY, $response->getObject()->offsetGet('validation_status')); + } + + public function testUpsertAddresses(): void + { + $content = json_encode(array_merge(self::createItems(), self::createCustomer())); + static::assertIsString($content); + + $customer = new CustomerEntity(); + $customer->setId(Uuid::randomHex()); + $customer->setDefaultShippingAddressId(Uuid::randomHex()); + $customer->setDefaultBillingAddressId(Uuid::randomHex()); + + $this->salesChannelContext + ->method('getCustomer') + ->willReturn($customer); + + $this->customerAddressRepository + ->expects($this->never()) + ->method('delete'); + + $this->cartService + ->expects($this->once()) + ->method('deleteCart'); + + $customerData = [ + 'customerData' => 'value', + 'shippingAddress' => [ + 'some' => 'data', + ], + 'billingAddress' => [ + 'other' => 'data', + ], + ]; + + $upsertData = [ + 'id' => $customer->getId(), + 'customerData' => 'value', + 'defaultShippingAddress' => [ + 'id' => $customer->getDefaultShippingAddressId(), + 'some' => 'data', + ], + 'defaultBillingAddress' => [ + 'id' => $customer->getDefaultBillingAddressId(), + 'other' => 'data', + ], + ]; + + $this->customerRepository + ->expects($this->never()) + ->method('delete'); + $this->customerRepository + ->expects($this->once()) + ->method('update') + ->with([$upsertData]); + + $this->shopwareCartTransformer + ->expects($this->once()) + ->method('extractCustomerData') + ->willReturn($customerData); + + $payPalCart = new PayPalCart(); + $payPalCart->setValidationStatus(PayPalCart::VALIDATION_STATUS__VALID); + $createResponse = new AgentCartResponse($payPalCart); + + $this->createCartRoute + ->expects($this->once()) + ->method('createCart') + ->willReturn($createResponse); + + $response = $this->updateCartRoute->updateCart('CART-11111111111111111111111111111111', new Request(content: $content), $this->salesChannelContext); + + static::assertSame(200, $response->getStatusCode()); + static::assertSame(PayPalCart::STATUS__READY, $response->getObject()->offsetGet('validation_status')); + } + + public function testDeleteBillingAddress(): void + { + $content = json_encode(array_merge(self::createItems(), self::createCustomer())); + static::assertIsString($content); + + $customer = new CustomerEntity(); + $customer->setId(Uuid::randomHex()); + $customer->setDefaultShippingAddressId(Uuid::randomHex()); + $customer->setDefaultBillingAddressId(Uuid::randomHex()); + + $this->salesChannelContext + ->method('getCustomer') + ->willReturn($customer); + + $this->customerAddressRepository + ->expects($this->once()) + ->method('delete') + ->with([['id' => $customer->getDefaultBillingAddressId()]]); + + $this->cartService + ->expects($this->once()) + ->method('deleteCart'); + + $customerData = [ + 'customerData' => 'value', + 'shippingAddress' => [ + 'some' => 'data', + ], + ]; + + $upsertData = [ + 'id' => $customer->getId(), + 'customerData' => 'value', + 'defaultShippingAddress' => [ + 'id' => $customer->getDefaultShippingAddressId(), + 'some' => 'data', + ], + 'defaultBillingAddressId' => $customer->getDefaultShippingAddressId(), + ]; + + $this->customerRepository + ->expects($this->never()) + ->method('delete'); + $this->customerRepository + ->expects($this->once()) + ->method('update') + ->with([$upsertData]); + + $this->shopwareCartTransformer + ->method('extractCustomerData') + ->willReturn($customerData); + + $payPalCart = new PayPalCart(); + $payPalCart->setValidationStatus(PayPalCart::VALIDATION_STATUS__VALID); + $createResponse = new AgentCartResponse($payPalCart); + + $this->createCartRoute + ->expects($this->once()) + ->method('createCart') + ->willReturn($createResponse); + + $response = $this->updateCartRoute->updateCart('CART-11111111111111111111111111111111', new Request(content: $content), $this->salesChannelContext); + + static::assertSame(200, $response->getStatusCode()); + static::assertSame(PayPalCart::STATUS__READY, $response->getObject()->offsetGet('validation_status')); + } + + public function testChangeShippingMethod(): void + { + $content = json_encode(array_merge(self::createItems(), self::createShippingOptions())); + static::assertIsString($content); + + $this->salesChannelContext + ->method('getCustomer') + ->willReturn(null); + + $this->cartService + ->expects($this->once()) + ->method('deleteCart'); + + $payPalCart = new PayPalCart(); + $payPalCart->setValidationStatus(PayPalCart::VALIDATION_STATUS__VALID); + $createResponse = new AgentCartResponse($payPalCart); + + $this->createCartRoute + ->expects($this->once()) + ->method('createCart') + ->willReturn($createResponse); + + $this->contextSwitchRoute + ->expects($this->once()) + ->method('switchContext') + ->willReturn(new ContextTokenResponse('some-token', 'some-url')); + + $response = $this->updateCartRoute->updateCart('CART-11111111111111111111111111111111', new Request(content: $content), $this->salesChannelContext); + + static::assertSame(200, $response->getStatusCode()); + static::assertSame(PayPalCart::STATUS__READY, $response->getObject()->offsetGet('validation_status')); + } + + public function testShippingMethodError(): void + { + $this->expectException(AgentException::class); + + $content = json_encode(array_merge(self::createItems(), self::createShippingOptions())); + static::assertIsString($content); + + $this->salesChannelContext + ->method('getCustomer') + ->willReturn(null); + + $this->cartService + ->expects($this->once()) + ->method('deleteCart'); + + $this->createCartRoute + ->expects($this->never()) + ->method('createCart'); + + $this->contextSwitchRoute + ->expects($this->once()) + ->method('switchContext') + ->willThrowException(new ConstraintViolationException(new ConstraintViolationList(), [])); + + $response = $this->updateCartRoute->updateCart('CART-11111111111111111111111111111111', new Request(content: $content), $this->salesChannelContext); + + static::assertSame(200, $response->getStatusCode()); + static::assertSame(PayPalCart::STATUS__READY, $response->getObject()->offsetGet('validation_status')); + } + + public function testRightShippingMethodSelected(): void + { + $options = self::createShippingOptions(); + + $content = json_encode(array_merge(self::createItems(), $options)); + static::assertIsString($content); + + $shippingMethod = new ShippingMethodEntity(); + $shippingMethod->setId($options['available_shipping_options'][1]['id']); + + $this->salesChannelContext + ->method('getCustomer') + ->willReturn(null); + $this->salesChannelContext + ->method('getShippingMethod') + ->willReturn($shippingMethod); + + $this->cartService + ->expects($this->once()) + ->method('deleteCart'); + + $payPalCart = new PayPalCart(); + $payPalCart->setValidationStatus(PayPalCart::VALIDATION_STATUS__VALID); + $createResponse = new AgentCartResponse($payPalCart); + + $this->createCartRoute + ->expects($this->once()) + ->method('createCart') + ->willReturn($createResponse); + + $this->contextSwitchRoute + ->expects($this->never()) + ->method('switchContext'); + + $response = $this->updateCartRoute->updateCart('CART-11111111111111111111111111111111', new Request(content: $content), $this->salesChannelContext); + + static::assertSame(200, $response->getStatusCode()); + static::assertSame(PayPalCart::STATUS__READY, $response->getObject()->offsetGet('validation_status')); + } + + private static function createItems(): array + { + return ['items' => [['variant_id' => Uuid::randomHex(), 'quantity' => 1]]]; + } + + private static function createCustomer(): array + { + return [ + 'customer' => [ + 'email_address' => 'email@example.com', + 'name' => ['given_name' => 'Mustermann', 'surname' => 'Max'], + ], + 'shipping_address' => [ + 'address_line_1' => '123 Main Street', + 'admin_area_2' => 'City', + 'country_code' => 'DE', + ], + 'billing_address' => [ + 'address_line_1' => '456 Other Street', + 'admin_area_2' => 'City 2', + 'country_code' => 'DE', + ], + ]; + } + + private static function createShippingOptions(): array + { + return [ + 'available_shipping_options' => [ + ['id' => Uuid::randomHex(), 'is_selected' => false], + ['id' => Uuid::randomHex(), 'is_selected' => true], + ], + ]; + } +} diff --git a/tests/AgentCommerce/Subscriber/ProductFilterSubscriberTest.php b/tests/AgentCommerce/Subscriber/ProductFilterSubscriberTest.php new file mode 100644 index 000000000..e1999c2cd --- /dev/null +++ b/tests/AgentCommerce/Subscriber/ProductFilterSubscriberTest.php @@ -0,0 +1,91 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\Test\AgentCommerce\Subscriber; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Shopware\Core\Content\Product\Events\ProductGatewayCriteriaEvent; +use Shopware\Core\Framework\Api\Context\AdminSalesChannelApiSource; +use Shopware\Core\Framework\Context; +use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria; +use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Uuid\Uuid; +use Shopware\Core\System\SalesChannel\SalesChannelContext; +use Swag\PayPal\AgentCommerce\Routing\AgentSource; +use Swag\PayPal\AgentCommerce\Subscriber\ProductFilterSubscriber; + +/** + * @internal + */ +#[Package('checkout')] +#[CoversClass(ProductFilterSubscriber::class)] +class ProductFilterSubscriberTest extends TestCase +{ + public function testNoAgentSource(): void + { + $salesChannelContext = $this->createMock(SalesChannelContext::class); + $salesChannelContext + ->method('getContext') + ->willReturn(Context::createCLIContext()); + + $criteria = new Criteria(); + $event = new ProductGatewayCriteriaEvent([], $criteria, $salesChannelContext); + + (new ProductFilterSubscriber())->onProductGatewayCriteria($event); + + static::assertCount(0, $criteria->getFilters()); + } + + public function testProductAddCriteria(): void + { + $source = new AgentSource('merchantId', new \DateTime(), new \DateTime(), [], Uuid::randomHex()); + $source->setStreamId(Uuid::randomHex()); + + $salesChannelContext = $this->createMock(SalesChannelContext::class); + $salesChannelContext + ->method('getContext') + ->willReturn(Context::createCLIContext($source)); + + $criteria = new Criteria(); + $event = new ProductGatewayCriteriaEvent([], $criteria, $salesChannelContext); + + (new ProductFilterSubscriber())->onProductGatewayCriteria($event); + + static::assertCount(1, $criteria->getFilters()); + $filter = $criteria->getFilters()[0]; + + static::assertInstanceOf(EqualsFilter::class, $filter); + static::assertSame('streams.id', $filter->getField()); + static::assertSame($source->getStreamId(), $filter->getValue()); + } + + public function testProductAddCriteriaAdminSalesChannelSource(): void + { + $originalSource = new AgentSource('merchantId', new \DateTime(), new \DateTime(), [], Uuid::randomHex()); + $originalSource->setStreamId(Uuid::randomHex()); + $source = new AdminSalesChannelApiSource(Uuid::randomHex(), Context::createCLIContext($originalSource)); + + $salesChannelContext = $this->createMock(SalesChannelContext::class); + $salesChannelContext + ->method('getContext') + ->willReturn(Context::createCLIContext($source)); + + $criteria = new Criteria(); + $event = new ProductGatewayCriteriaEvent([], $criteria, $salesChannelContext); + + (new ProductFilterSubscriber())->onProductGatewayCriteria($event); + + static::assertCount(1, $criteria->getFilters()); + $filter = $criteria->getFilters()[0]; + + static::assertInstanceOf(EqualsFilter::class, $filter); + static::assertSame('streams.id', $filter->getField()); + static::assertSame($originalSource->getStreamId(), $filter->getValue()); + } +} diff --git a/tests/AgentCommerce/Subscriber/WebhookSubscriberTest.php b/tests/AgentCommerce/Subscriber/WebhookSubscriberTest.php new file mode 100644 index 000000000..6076df34c --- /dev/null +++ b/tests/AgentCommerce/Subscriber/WebhookSubscriberTest.php @@ -0,0 +1,141 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\Test\AgentCommerce\Subscriber; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Shopware\Core\Framework\Api\Context\AdminApiSource; +use Shopware\Core\Framework\Context; +use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository; +use Shopware\Core\Framework\DataAbstractionLayer\EntityWriteResult; +use Shopware\Core\Framework\DataAbstractionLayer\Event\EntityWrittenEvent; +use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria; +use Shopware\Core\Framework\DataAbstractionLayer\Search\IdSearchResult; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Uuid\Uuid; +use Shopware\Core\System\SalesChannel\SalesChannelDefinition; +use Swag\PayPal\AgentCommerce\HoneyWebhookResult; +use Swag\PayPal\AgentCommerce\HoneyWebhookService; +use Swag\PayPal\AgentCommerce\Subscriber\WebhookSubscriber; + +/** + * @internal + */ +#[Package('checkout')] +#[CoversClass(WebhookSubscriber::class)] +class WebhookSubscriberTest extends TestCase +{ + public function testTest(): void + { + $deleteResult = new EntityWriteResult( + $deleteId = Uuid::randomHex(), + [], + SalesChannelDefinition::ENTITY_NAME, + EntityWriteResult::OPERATION_DELETE + ); + + $noPayloadResult = new EntityWriteResult( + Uuid::randomHex(), + ['other' => 'properties'], + SalesChannelDefinition::ENTITY_NAME, + EntityWriteResult::OPERATION_UPDATE + ); + + $activateResult = new EntityWriteResult( + $activateId = Uuid::randomHex(), + ['active' => true], + SalesChannelDefinition::ENTITY_NAME, + EntityWriteResult::OPERATION_UPDATE + ); + + $deactivateResult = new EntityWriteResult( + $deactiveId = Uuid::randomHex(), + ['active' => false], + SalesChannelDefinition::ENTITY_NAME, + EntityWriteResult::OPERATION_UPDATE + ); + + $event = new EntityWrittenEvent( + SalesChannelDefinition::ENTITY_NAME, + [$deleteResult, $noPayloadResult, $activateResult, $deactivateResult], + Context::createCLIContext(new AdminApiSource(Uuid::randomHex())), + [] + ); + + $salesChannelRepository = $this->createMock(EntityRepository::class); + $salesChannelRepository + ->expects($this->once()) + ->method('searchIds') + ->willReturnCallback(static function (Criteria $criteria) use ($deleteId, $activateId, $deactiveId) { + static::assertSame([$deleteId, $activateId, $deactiveId], $criteria->getIds()); + + $data = [ + ['primaryKey' => $activateId, 'data' => []], + ['primaryKey' => $deactiveId, 'data' => []], + ]; + + return new IdSearchResult(2, $data, $criteria, Context::createCLIContext()); + }); + + $webhookResult = new HoneyWebhookResult(true, 'success message', null); + $webhook = $this->createMock(HoneyWebhookService::class); + $webhook + ->expects($this->once()) + ->method('register') + ->with($activateId) + ->willReturn($webhookResult); + $webhook + ->expects($this->exactly(2)) + ->method('deregister') + ->willReturn($webhookResult); + + $notificationRepository = $this->createMock(EntityRepository::class); + $notificationRepository + ->expects($this->exactly(3)) + ->method('create'); + + $subscriber = new WebhookSubscriber( + $salesChannelRepository, + $webhook, + $notificationRepository, + ); + + $subscriber->handleWebhookLifecycle($event); + } + + public function testEmptyWriteResult(): void + { + $salesChannelRepository = $this->createMock(EntityRepository::class); + $salesChannelRepository + ->expects($this->never()) + ->method('searchIds'); + + $webhook = $this->createMock(HoneyWebhookService::class); + $webhook + ->expects($this->never()) + ->method('register'); + $webhook + ->expects($this->never()) + ->method('deregister'); + + $notificationRepository = $this->createMock(EntityRepository::class); + $notificationRepository + ->expects($this->never()) + ->method('create'); + + $subscriber = new WebhookSubscriber( + $salesChannelRepository, + $webhook, + $notificationRepository, + ); + + $event = new EntityWrittenEvent(SalesChannelDefinition::ENTITY_NAME, [], Context::createCLIContext(), []); + + $subscriber->handleWebhookLifecycle($event); + } +} diff --git a/tests/AgentCommerce/Util/AgentDebugIDProcessorTest.php b/tests/AgentCommerce/Util/AgentDebugIDProcessorTest.php new file mode 100644 index 000000000..1601b88f7 --- /dev/null +++ b/tests/AgentCommerce/Util/AgentDebugIDProcessorTest.php @@ -0,0 +1,174 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\Tests\AgentCommerce\Util; + +use Monolog\Level; +use Monolog\LogRecord; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Shopware\Core\Framework\Api\Context\SystemSource; +use Shopware\Core\Framework\Context; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Routing\AbstractRouteScope; +use Shopware\Core\Framework\Routing\RouteScopeRegistry; +use Shopware\Core\PlatformRequest; +use Swag\PayPal\AgentCommerce\Routing\AgentRouteScope; +use Swag\PayPal\AgentCommerce\Routing\AgentSource; +use Swag\PayPal\AgentCommerce\Util\AgentDebugIDProcessor; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; + +/** + * @internal + */ +#[Package('checkout')] +#[CoversClass(AgentDebugIDProcessor::class)] +class AgentDebugIDProcessorTest extends TestCase +{ + public function testInvokeWithoutRequest(): void + { + $request = new Request(); + $request->attributes->set(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, [AgentRouteScope::ID]); + + $processor = self::createProcessor($request, [new AgentRouteScope()]); + $record = self::createLogRecord(); + + $record = $processor($record); + + static::assertSame([], $record->extra); + } + + public function testInvokeWithNonAgentScope(): void + { + $request = new Request(); + $request->attributes->set(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, ['invalid_scope']); + + $invalidScope = new class extends AbstractRouteScope { + public function getId(): string + { + return 'invalid_scope'; + } + + public function isAllowed(Request $request): bool + { + return true; + } + }; + + $processor = self::createProcessor($request, [$invalidScope]); + $record = self::createLogRecord(); + + $record = $processor($record); + + static::assertSame([], $record->extra); + } + + public function testInvokeWithoutContextObject(): void + { + $request = new Request(); + $request->attributes->set(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, [AgentRouteScope::ID]); + + $processor = self::createProcessor($request, [new AgentRouteScope()]); + $record = self::createLogRecord(); + + $record = $processor($record); + + static::assertSame([], $record->extra); + } + + public function testInvokeWithNonContextObject(): void + { + $request = new Request(); + $request->attributes->set(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, [AgentRouteScope::ID]); + $request->attributes->set(PlatformRequest::ATTRIBUTE_CONTEXT_OBJECT, new \stdClass()); + + $processor = self::createProcessor($request, [new AgentRouteScope()]); + $record = self::createLogRecord(); + + $record = $processor($record); + + static::assertSame([], $record->extra); + } + + public function testInvokeWithNonAgentSource(): void + { + $context = Context::createDefaultContext(new SystemSource()); + + $request = new Request(); + $request->attributes->set(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, [AgentRouteScope::ID]); + $request->attributes->set(PlatformRequest::ATTRIBUTE_CONTEXT_OBJECT, $context); + + $processor = self::createProcessor($request, [new AgentRouteScope()]); + $record = self::createLogRecord(); + + $record = $processor($record); + + static::assertSame([], $record->extra); + } + + public function testInvokeWithoutDebugId(): void + { + $source = new AgentSource('test-agent', new \DateTimeImmutable(), new \DateTimeImmutable(), ['test'], 'sales-channel-id'); + + $context = Context::createDefaultContext($source); + + $request = new Request(); + $request->attributes->set(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, [AgentRouteScope::ID]); + $request->attributes->set(PlatformRequest::ATTRIBUTE_CONTEXT_OBJECT, $context); + + $processor = self::createProcessor($request, [new AgentRouteScope()]); + $record = self::createLogRecord(); + + $record = $processor($record); + + static::assertSame([], $record->extra); + } + + public function testInvoke(): void + { + $source = new AgentSource('test-agent', new \DateTimeImmutable(), new \DateTimeImmutable(), ['test'], 'sales-channel-id', 'debug-id-123'); + + $context = Context::createDefaultContext($source); + + $request = new Request(); + $request->attributes->set(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, [AgentRouteScope::ID]); + $request->attributes->set(PlatformRequest::ATTRIBUTE_CONTEXT_OBJECT, $context); + + $processor = self::createProcessor($request, [new AgentRouteScope()]); + $record = self::createLogRecord(); + + $record = $processor($record); + + static::assertSame(['debugId' => 'debug-id-123'], $record->extra); + } + + /** + * @param list $scopes + */ + private static function createProcessor(?Request $request = null, array $scopes = []): AgentDebugIDProcessor + { + $requestStack = new RequestStack(); + + if ($request) { + $requestStack->push($request); + } + + $routeScopeRegistry = new RouteScopeRegistry($scopes); + + $processor = new AgentDebugIDProcessor(); + $processor->setRequestStack($requestStack); + $processor->setRouteScopeRegistry($routeScopeRegistry); + + return $processor; + } + + private static function createLogRecord(): LogRecord + { + return new LogRecord(new \DateTimeImmutable(), 'test', Level::Debug, 'Test message', [], []); + } +} diff --git a/tests/AgentCommerce/Util/FaviconLoaderTest.php b/tests/AgentCommerce/Util/FaviconLoaderTest.php new file mode 100644 index 000000000..1550831cb --- /dev/null +++ b/tests/AgentCommerce/Util/FaviconLoaderTest.php @@ -0,0 +1,124 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\Tests\AgentCommerce\Util; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Shopware\Core\Framework\Context; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Uuid\Uuid; +use Shopware\Core\System\SalesChannel\Context\SalesChannelContextService; +use Shopware\Core\System\SalesChannel\SalesChannelContext; +use Shopware\Storefront\Theme\AbstractResolvedConfigLoader; +use Shopware\Storefront\Theme\ConfigLoader\AbstractAvailableThemeProvider; +use Swag\PayPal\AgentCommerce\Exception\HoneyWebhookException; +use Swag\PayPal\AgentCommerce\Util\FaviconLoader; + +/** + * @internal + */ +#[Package('checkout')] +#[CoversClass(FaviconLoader::class)] +class FaviconLoaderTest extends TestCase +{ + public function testThemeIdNotFound(): void + { + $this->expectException(HoneyWebhookException::class); + $this->expectExceptionMessage('Storefront sales channel not found'); + + $themeProviderMock = $this->createMock(AbstractAvailableThemeProvider::class); + $themeProviderMock + ->expects($this->once()) + ->method('load') + ->willReturn([]); + + $faviconLoader = new FaviconLoader( + $themeProviderMock, + $this->createMock(AbstractResolvedConfigLoader::class), + $this->createMock(SalesChannelContextService::class), + ); + + $faviconLoader->loadFaviconLink(Uuid::randomHex(), Context::createCLIContext()); + } + + public function testLoadFaviconLink(): void + { + $themeId = Uuid::randomHex(); + $salesChannelId = Uuid::randomHex(); + $context = Context::createCLIContext(); + + $themeProviderMock = $this->createMock(AbstractAvailableThemeProvider::class); + $themeProviderMock + ->expects($this->once()) + ->method('load') + ->willReturn([$salesChannelId => $themeId]); + + $salesChannelMock = $this->createMock(SalesChannelContext::class); + + $contextServiceMock = $this->createMock(SalesChannelContextService::class); + $contextServiceMock + ->expects($this->once()) + ->method('get') + ->willReturn($salesChannelMock); + + $configLoaderMock = $this->createMock(AbstractResolvedConfigLoader::class); + $configLoaderMock + ->expects($this->once()) + ->method('load') + ->with($themeId, $salesChannelMock) + ->willReturn(['sw-logo-favicon' => 'https://example.com/favicon.ico']); + + $faviconLoader = new FaviconLoader( + $themeProviderMock, + $configLoaderMock, + $contextServiceMock, + ); + + $link = $faviconLoader->loadFaviconLink($salesChannelId, $context); + + static::assertSame('https://example.com/favicon.ico', $link); + } + + public function testLoadFaviconEmptyLink(): void + { + $themeId = Uuid::randomHex(); + $salesChannelId = Uuid::randomHex(); + $context = Context::createCLIContext(); + + $themeProviderMock = $this->createMock(AbstractAvailableThemeProvider::class); + $themeProviderMock + ->expects($this->once()) + ->method('load') + ->willReturn([$salesChannelId => $themeId]); + + $salesChannelMock = $this->createMock(SalesChannelContext::class); + + $contextServiceMock = $this->createMock(SalesChannelContextService::class); + $contextServiceMock + ->expects($this->once()) + ->method('get') + ->willReturn($salesChannelMock); + + $configLoaderMock = $this->createMock(AbstractResolvedConfigLoader::class); + $configLoaderMock + ->expects($this->once()) + ->method('load') + ->with($themeId, $salesChannelMock) + ->willReturn([]); + + $faviconLoader = new FaviconLoader( + $themeProviderMock, + $configLoaderMock, + $contextServiceMock, + ); + + $link = $faviconLoader->loadFaviconLink($salesChannelId, $context); + + static::assertSame('', $link); + } +} diff --git a/tests/AgentCommerce/Util/PayPalCartFactoryTest.php b/tests/AgentCommerce/Util/PayPalCartFactoryTest.php new file mode 100644 index 000000000..2698eb479 --- /dev/null +++ b/tests/AgentCommerce/Util/PayPalCartFactoryTest.php @@ -0,0 +1,115 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\Tests\AgentCommerce\Util; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Uuid\Uuid; +use Swag\PayPal\AgentCommerce\Exception\AgentException; +use Swag\PayPal\AgentCommerce\Util\PayPalCartFactory; + +/** + * @internal + */ +#[Package('checkout')] +#[CoversClass(PayPalCartFactory::class)] +class PayPalCartFactoryTest extends TestCase +{ + #[DataProvider('dataProviderCartData')] + public function testCreatePayPalCart(array $data, ?AgentException $expectedException = null): void + { + if ($expectedException) { + $this->expectException($expectedException::class); + $this->expectExceptionMessage($expectedException->getMessage()); + } + + $payPalCart = (new PayPalCartFactory())->create($data); + + static::assertTrue($payPalCart->isset('items')); + static::assertGreaterThan(0, $payPalCart->getItems()->count()); + } + + public static function dataProviderCartData(): array + { + return [ + 'no items' => [[], AgentException::requiredFieldsMissing('cart.items')], + 'empty items' => [['items' => []], AgentException::requiredFieldsMissing('cart.items')], + 'no variantId' => [['items' => [self::createCartItem(null, 1)]], AgentException::requiredFieldsMissing(\sprintf('cart.items.%s.variantId', 0))], + 'invalid variantId' => [['items' => [self::createCartItem('asdf', 1)]], AgentException::requiredFieldInvalid(\sprintf('cart.items.%s.variantId', 0), 'Not a valid UUID')], + 'no quantity' => [['items' => [self::createCartItem(Uuid::randomHex())]], AgentException::requiredFieldsMissing(\sprintf('cart.items.%s.quantity', 0))], + 'valid item' => [self::createItems()], + 'valid customer with item' => [array_merge(self::createItems(), self::createCustomer())], + 'empty customer' => [['customer' => ['random_property' => 'value']], AgentException::requiredFieldsMissing('cart.customer.emailAddress')], + 'no customer email' => [self::unsetProperty(self::createCustomer(), 'customer', 'email_address'), AgentException::requiredFieldsMissing('cart.customer.emailAddress')], + 'no customer name' => [self::unsetProperty(self::createCustomer(), 'customer', 'name'), AgentException::requiredFieldsMissing('cart.customer.name')], + 'no customer shipping address' => [self::unsetProperty(self::createCustomer(), 'shipping_address'), AgentException::requiredFieldsMissing('cart.shippingAddress')], + 'no customer shipping address address line' => [self::unsetProperty(self::createCustomer(), 'shipping_address', 'address_line_1'), AgentException::requiredFieldsMissing('address.addressLine1')], + 'no customer shipping address admin area' => [self::unsetProperty(self::createCustomer(), 'shipping_address', 'admin_area_2'), AgentException::requiredFieldsMissing('address.adminArea2')], + 'no customer shipping address country code' => [self::unsetProperty(self::createCustomer(), 'shipping_address', 'country_code'), AgentException::requiredFieldsMissing('address.countryCode')], + 'no customer billing address address line' => [self::unsetProperty(self::createCustomer(), 'billing_address', 'address_line_1'), AgentException::requiredFieldsMissing('address.addressLine1')], + 'no customer billing address admin area' => [self::unsetProperty(self::createCustomer(), 'billing_address', 'admin_area_2'), AgentException::requiredFieldsMissing('address.adminArea2')], + 'no customer billing address country code' => [self::unsetProperty(self::createCustomer(), 'billing_address', 'country_code'), AgentException::requiredFieldsMissing('address.countryCode')], + ]; + } + + private static function createItems(): array + { + return ['items' => [self::createCartItem(Uuid::randomHex(), 1)]]; + } + + private static function createCartItem(?string $variantId = null, ?int $quantity = null): array + { + $item = []; + if ($variantId !== null) { + $item['variant_id'] = $variantId; + } + + if ($quantity !== null) { + $item['quantity'] = $quantity; + } + + return $item; + } + + private static function createCustomer(): array + { + return [ + 'customer' => [ + 'email_address' => 'email@example.com', + 'name' => ['given_name' => 'Mustermann', 'surname' => 'Max'], + ], + 'shipping_address' => [ + 'address_line_1' => '123 Main Street', + 'admin_area_2' => 'City', + 'country_code' => 'DE', + ], + 'billing_address' => [ + 'address_line_1' => '456 Other Street', + 'admin_area_2' => 'City 2', + 'country_code' => 'DE', + ], + ]; + } + + private static function unsetProperty(array $data, string $property, ?string $chained = null): array + { + if (!isset($data[$property])) { + return $data; + } + + if ($chained && isset($data[$property][$chained])) { + unset($data[$property][$chained]); + } else { + unset($data[$property]); + } + + return $data; + } +} diff --git a/tests/AgentCommerce/Util/PayPalCartTransformerTest.php b/tests/AgentCommerce/Util/PayPalCartTransformerTest.php new file mode 100644 index 000000000..636dc2d83 --- /dev/null +++ b/tests/AgentCommerce/Util/PayPalCartTransformerTest.php @@ -0,0 +1,698 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\Tests\AgentCommerce\Util; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Shopware\Core\Checkout\Cart\Cart; +use Shopware\Core\Checkout\Cart\Delivery\Struct\Delivery; +use Shopware\Core\Checkout\Cart\Delivery\Struct\DeliveryCollection; +use Shopware\Core\Checkout\Cart\Delivery\Struct\DeliveryDate; +use Shopware\Core\Checkout\Cart\Delivery\Struct\DeliveryPositionCollection; +use Shopware\Core\Checkout\Cart\Delivery\Struct\ShippingLocation; +use Shopware\Core\Checkout\Cart\Error\Error; +use Shopware\Core\Checkout\Cart\LineItem\LineItem; +use Shopware\Core\Checkout\Cart\LineItem\LineItemCollection; +use Shopware\Core\Checkout\Cart\Price\Struct\CalculatedPrice; +use Shopware\Core\Checkout\Cart\Price\Struct\CartPrice; +use Shopware\Core\Checkout\Cart\Tax\Struct\CalculatedTax; +use Shopware\Core\Checkout\Cart\Tax\Struct\CalculatedTaxCollection; +use Shopware\Core\Checkout\Cart\Tax\Struct\TaxRuleCollection; +use Shopware\Core\Checkout\Customer\Aggregate\CustomerAddress\CustomerAddressEntity; +use Shopware\Core\Checkout\Customer\CustomerEntity; +use Shopware\Core\Checkout\Promotion\Cart\PromotionCartAddedInformationError; +use Shopware\Core\Checkout\Shipping\SalesChannel\AbstractShippingMethodRoute; +use Shopware\Core\Checkout\Shipping\SalesChannel\ShippingMethodRouteResponse; +use Shopware\Core\Checkout\Shipping\ShippingMethodCollection; +use Shopware\Core\Checkout\Shipping\ShippingMethodEntity; +use Shopware\Core\Content\Product\Cart\ProductNotFoundError; +use Shopware\Core\Content\Product\Cart\PurchaseStepsError; +use Shopware\Core\Framework\Context; +use Shopware\Core\Framework\DataAbstractionLayer\EntityCollection; +use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository; +use Shopware\Core\Framework\DataAbstractionLayer\PartialEntity; +use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria; +use Shopware\Core\Framework\DataAbstractionLayer\Search\EntitySearchResult; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Uuid\Uuid; +use Shopware\Core\System\Country\CountryEntity; +use Shopware\Core\System\Currency\CurrencyEntity; +use Shopware\Core\System\DeliveryTime\DeliveryTimeEntity; +use Shopware\Core\System\SalesChannel\Context\LanguageInfo; +use Shopware\Core\System\SalesChannel\SalesChannelContext; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\Address; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\AppliedCoupon; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\AppliedCouponCollection; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\BillingAddress; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\CartItem; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\CartItemCollection; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\CartTotals; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\Customer; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\Money; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\PayPalCart; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\ShippingAddress; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\ShippingOption; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\ShippingOptionCollection; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\ValidationIssue; +use Swag\PayPal\AgentCommerce\Exception\AgentException; +use Swag\PayPal\AgentCommerce\Util\PayPalCartTransformer; +use Swag\PayPal\AgentCommerce\Validation\ValidationIssues; + +/** + * @internal + */ +#[Package('checkout')] +#[CoversClass(PayPalCartTransformer::class)] +class PayPalCartTransformerTest extends TestCase +{ + public function testConvertToPayPalCart(): void + { + $transformer = new PayPalCartTransformer( + $this->createMock(EntityRepository::class), + $this->createMock(EntityRepository::class), + $this->createMock(AbstractShippingMethodRoute::class), + $this->createMock(ValidationIssues::class), + ); + + $currency = new CurrencyEntity(); + $currency->setIsoCode('EUR'); + + $country = new CountryEntity(); + $country->setIso('DE'); + + $address = new CustomerAddressEntity(); + $address->setCountry($country); + $address->setZipcode('12345'); + $address->setStreet('Mainstreet 1'); + $address->setCity('City 1'); + + $customer = self::createCustomer(null); + $customer->setDefaultShippingAddress($address); + $customer->setDefaultBillingAddress($address); + + $context = $this->createMock(SalesChannelContext::class); + $context + ->method('getCurrency') + ->willReturn($currency); + $context + ->method('getCustomer') + ->willReturn($customer); + + $cart = new Cart('some-token'); + + $payPalCart = $transformer->convertToPayPalCart($cart, $context); + + static::assertSame('CART-some-token', $payPalCart->getId()); + static::assertSame(PayPalCart::VALIDATION_STATUS__VALID, $payPalCart->getValidationStatus()); + static::assertInstanceOf(CartTotals::class, $payPalCart->getTotals()); + static::assertInstanceOf(ShippingOptionCollection::class, $payPalCart->getAvailableShippingOptions()); + static::assertInstanceOf(Customer::class, $payPalCart->getCustomer()); + static::assertInstanceOf(Address::class, $payPalCart->getShippingAddress()); + static::assertInstanceOf(Address::class, $payPalCart->getBillingAddress()); + static::assertTrue($payPalCart->isset('validationIssues')); + static::assertTrue($payPalCart->isset('items')); + } + + public function testConvertToCartItems(): void + { + $transformer = new PayPalCartTransformer( + $this->createMock(EntityRepository::class), + $this->createMock(EntityRepository::class), + $this->createMock(AbstractShippingMethodRoute::class), + $this->createMock(ValidationIssues::class), + ); + + $currency = new CurrencyEntity(); + $currency->setIsoCode('EUR'); + + $context = $this->createMock(SalesChannelContext::class); + $context + ->method('getCurrency') + ->willReturn($currency); + + $noPriceId = Uuid::randomHex(); + $lineItem1 = new LineItem($noPriceId, 'product', $noPriceId, 10); + $lineItem1->setLabel('Item Label'); + + $noParentId = Uuid::randomHex(); + $lineItem2 = new LineItem($noParentId, 'product', $noParentId, 10); + $lineItem2->setPrice(new CalculatedPrice(100, 1000, new CalculatedTaxCollection(), new TaxRuleCollection())); + $lineItem2->setLabel('Item Label'); + + $withParentId = Uuid::randomHex(); + $lineItem3 = new LineItem($withParentId, 'product', $withParentId, 10); + $lineItem3->setPrice(new CalculatedPrice(100, 1000, new CalculatedTaxCollection(), new TaxRuleCollection())); + $lineItem3->setPayloadValue('parentId', 'someParentId'); + $lineItem3->setLabel('Item Label'); + + $cartItems = $transformer->convertToCartItems([$lineItem1, $lineItem2, $lineItem3], $context); + static::assertCount(2, $cartItems); + + $noParentItem = $cartItems->get(0); + $withParentItem = $cartItems->get(1); + + static::assertInstanceOf(CartItem::class, $noParentItem); + static::assertInstanceOf(CartItem::class, $withParentItem); + + static::assertSame('Item Label', $noParentItem->getName()); + static::assertSame($noParentId, $noParentItem->getVariantId()); + static::assertSame(10, $noParentItem->getQuantity()); + static::assertNull($noParentItem->getParentId()); + static::assertSame('100', $noParentItem->getPrice()?->getValue()); + static::assertSame('EUR', $noParentItem->getPrice()->getCurrencyCode()); + + static::assertSame('Item Label', $withParentItem->getName()); + static::assertSame($withParentId, $withParentItem->getVariantId()); + static::assertSame(10, $withParentItem->getQuantity()); + static::assertSame('someParentId', $withParentItem->getParentId()); + static::assertSame('100', $withParentItem->getPrice()?->getValue()); + static::assertSame('EUR', $withParentItem->getPrice()->getCurrencyCode()); + } + + public function testConvertToAvailableShippingMethods(): void + { + $deliveryTime = new DeliveryTimeEntity(); + $deliveryTime->setId(Uuid::randomHex()); + $deliveryTime->setTranslated(['name' => 'DeliveryTime']); + $deliveryTime->setUnit(DeliveryTimeEntity::DELIVERY_TIME_DAY); + $deliveryTime->setMin(1); + $deliveryTime->setMax(2); + + $shippingMethodId1 = Uuid::randomHex(); + $shippingMethodId2 = Uuid::randomHex(); + $shippingMethodId3 = Uuid::randomHex(); + $shippingMethod1 = new ShippingMethodEntity(); + $shippingMethod1->setId($shippingMethodId1); + $shippingMethod1->setTranslated(['name' => 'Label 1', 'description' => 'Description 1']); + $shippingMethod1->setDeliveryTime($deliveryTime); + $shippingMethod2 = new ShippingMethodEntity(); + $shippingMethod2->setId($shippingMethodId2); + $shippingMethod2->setTranslated(['name' => 'Label 2', 'description' => 'Description 2']); + $shippingMethod2->setDeliveryTime($deliveryTime); + $shippingMethod3 = new ShippingMethodEntity(); + $shippingMethod3->setId($shippingMethodId3); + $shippingMethod3->setTranslated(['name' => 'Label 3', 'description' => 'Description 3']); + $shippingMethod3->setDeliveryTime($deliveryTime); + + $result = new EntitySearchResult( + 'shipping_method', + 3, + new ShippingMethodCollection([$shippingMethod1, $shippingMethod2, $shippingMethod3]), + null, + new Criteria(), + Context::createCLIContext() + ); + + $shippingRouteMock = $this->createMock(AbstractShippingMethodRoute::class); + $shippingRouteMock + ->expects($this->once()) + ->method('load') + ->willReturn(new ShippingMethodRouteResponse($result)); + + $transformer = new PayPalCartTransformer( + $this->createMock(EntityRepository::class), + $this->createMock(EntityRepository::class), + $shippingRouteMock, + $this->createMock(ValidationIssues::class), + ); + + $currency = new CurrencyEntity(); + $currency->setIsoCode('EUR'); + + $context = $this->createMock(SalesChannelContext::class); + $context + ->method('getCurrency') + ->willReturn($currency); + + $delivery1 = clone $delivery2 = clone $delivery3 = new Delivery( + new DeliveryPositionCollection(), + new DeliveryDate(new \DateTime(), new \DateTime()), + new ShippingMethodEntity(), + new ShippingLocation(new CountryEntity()), + new CalculatedPrice(10, 10, new CalculatedTaxCollection(), new TaxRuleCollection()) + ); + + $delivery1->setShippingMethod($shippingMethod1); + $delivery2->setShippingMethod($shippingMethod2); + $delivery3->setShippingMethod(clone $shippingMethod1); + + $cart = new Cart(Uuid::randomHex()); + $cart->setDeliveries(new DeliveryCollection([$delivery1, $delivery2, $delivery3])); + + $availableShippingMethods = $transformer->convertToAvailableShippingMethods($cart, $context); + $first = $availableShippingMethods->get(0); + $second = $availableShippingMethods->get(1); + $third = $availableShippingMethods->get(2); + + static::assertCount(3, $availableShippingMethods); + static::assertInstanceOf(ShippingOption::class, $first); + static::assertInstanceOf(ShippingOption::class, $second); + static::assertInstanceOf(ShippingOption::class, $third); + + static::assertSame($shippingMethodId1, $first->getId()); + static::assertSame('Label 1 (DeliveryTime)', $first->getName()); + static::assertSame('Description 1', $first->getDescription()); + static::assertTrue($first->isset('price')); + static::assertTrue($first->isSelected()); + static::assertSame('20', $first->getPrice()->getValue()); + static::assertSame('EUR', $first->getPrice()->getCurrencyCode()); + static::assertNotNull($first->getEstimatedDelivery()); + + static::assertSame($shippingMethodId2, $second->getId()); + static::assertSame('Label 2 (DeliveryTime)', $second->getName()); + static::assertSame('Description 2', $second->getDescription()); + static::assertTrue($second->isset('price')); + static::assertTrue($second->isSelected()); + static::assertSame('10', $second->getPrice()->getValue()); + static::assertSame('EUR', $second->getPrice()->getCurrencyCode()); + static::assertNotNull($second->getEstimatedDelivery()); + + static::assertSame($shippingMethodId3, $third->getId()); + static::assertSame('Label 3 (DeliveryTime)', $third->getName()); + static::assertSame('Description 3', $third->getDescription()); + static::assertFalse($third->isset('price')); + static::assertFalse($third->isSelected()); + static::assertNotNull($third->getEstimatedDelivery()); + } + + public function testConvertNullCustomer(): void + { + $transformer = new PayPalCartTransformer( + $this->createMock(EntityRepository::class), + $this->createMock(EntityRepository::class), + $this->createMock(AbstractShippingMethodRoute::class), + $this->createMock(ValidationIssues::class), + ); + + static::assertNull($transformer->convertCustomer(null)); + } + + public function testConvertCustomerNoPhoneNumber(): void + { + $transformer = new PayPalCartTransformer( + $this->createMock(EntityRepository::class), + $this->createMock(EntityRepository::class), + $this->createMock(AbstractShippingMethodRoute::class), + $this->createMock(ValidationIssues::class), + ); + + $converted = $transformer->convertCustomer(self::createCustomer(null)); + + static::assertInstanceOf(Customer::class, $converted); + static::assertSame('Max', $converted->getName()->getGivenName()); + static::assertSame('Mustermann', $converted->getName()->getSurname()); + static::assertNull($converted->getPhone()); + } + + public function testConvertCustomerValidPhoneNumber(): void + { + $transformer = new PayPalCartTransformer( + $this->createMock(EntityRepository::class), + $this->createMock(EntityRepository::class), + $this->createMock(AbstractShippingMethodRoute::class), + $this->createMock(ValidationIssues::class), + ); + + $converted = $transformer->convertCustomer(self::createCustomer('+12 12345-67890')); + + static::assertInstanceOf(Customer::class, $converted); + static::assertSame('Max', $converted->getName()->getGivenName()); + static::assertSame('Mustermann', $converted->getName()->getSurname()); + static::assertSame('+12 12345-67890', $converted->getPhone()?->getFullPhoneNumber()); + static::assertSame('12', $converted->getPhone()->getCountryCode()); + static::assertSame('12345', $converted->getPhone()->getNationalNumber()); + static::assertSame('67890', $converted->getPhone()->getExtensionNumber()); + } + + public function testConvertCustomerInvalidPhoneNumber(): void + { + $transformer = new PayPalCartTransformer( + $this->createMock(EntityRepository::class), + $this->createMock(EntityRepository::class), + $this->createMock(AbstractShippingMethodRoute::class), + $this->createMock(ValidationIssues::class), + ); + + $converted = $transformer->convertCustomer(self::createCustomer('1234567890')); + + static::assertInstanceOf(Customer::class, $converted); + static::assertSame('Max', $converted->getName()->getGivenName()); + static::assertSame('Mustermann', $converted->getName()->getSurname()); + static::assertNull($converted->getPhone()); + } + + public function testCreateTotals(): void + { + $transformer = new PayPalCartTransformer( + $this->createMock(EntityRepository::class), + $this->createMock(EntityRepository::class), + $this->createMock(AbstractShippingMethodRoute::class), + $this->createMock(ValidationIssues::class), + ); + + $currency = new CurrencyEntity(); + $currency->setIsoCode('EUR'); + + $context = $this->createMock(SalesChannelContext::class); + $context + ->method('getCurrency') + ->willReturn($currency); + + $totals = $transformer->createTotals($this->createCart(), $context); + + static::assertSame('26', $totals->getTax()?->getValue()); + static::assertSame('EUR', $totals->getTax()->getCurrencyCode()); + static::assertSame('226', $totals->getTotal()->getValue()); + static::assertSame('EUR', $totals->getTotal()->getCurrencyCode()); + static::assertSame('15', $totals->getShipping()?->getValue()); + static::assertSame('EUR', $totals->getShipping()->getCurrencyCode()); + static::assertSame('185', $totals->getSubtotal()?->getValue()); + static::assertSame('EUR', $totals->getSubtotal()->getCurrencyCode()); + } + + public function testConvertAddressNoIsoFound(): void + { + $this->expectException(AgentException::class); + + $transformer = new PayPalCartTransformer( + $this->createMock(EntityRepository::class), + $this->createMock(EntityRepository::class), + $this->createMock(AbstractShippingMethodRoute::class), + $this->createMock(ValidationIssues::class), + ); + + $address = new CustomerAddressEntity(); + $address->setCountryId(Uuid::randomHex()); + + $transformer->convertAddress($address, ShippingAddress::class, Context::createCLIContext()); + } + + public function testConvertNullAddress(): void + { + $transformer = new PayPalCartTransformer( + $this->createMock(EntityRepository::class), + $this->createMock(EntityRepository::class), + $this->createMock(AbstractShippingMethodRoute::class), + $this->createMock(ValidationIssues::class), + ); + + static::assertNull($transformer->convertAddress(null, ShippingAddress::class, Context::createCLIContext())); + } + + public function testConvertAddress(): void + { + $entity = new PartialEntity(); + $entity->setUniqueIdentifier(Uuid::randomHex()); + $entity->set('iso', 'DE'); + + $result = new EntitySearchResult( + 'country', + 1, + new EntityCollection([$entity]), + null, + new Criteria(), + Context::createCLIContext() + ); + + $repository = $this->createMock(EntityRepository::class); + $repository + ->method('search') + ->willReturn($result); + + $transformer = new PayPalCartTransformer( + $this->createMock(EntityRepository::class), + $repository, + $this->createMock(AbstractShippingMethodRoute::class), + $this->createMock(ValidationIssues::class), + ); + + $address = new CustomerAddressEntity(); + $address->setCountryId(Uuid::randomHex()); + $address->setZipcode('12345'); + $address->setStreet('Mainstreet 1'); + $address->setCity('City 1'); + + $shippingAddress = $transformer->convertAddress($address, ShippingAddress::class, Context::createCLIContext()); + $billingAddress = $transformer->convertAddress($address, BillingAddress::class, Context::createCLIContext()); + + static::assertInstanceOf(ShippingAddress::class, $shippingAddress); + static::assertInstanceOf(BillingAddress::class, $billingAddress); + + static::assertSame('12345', $shippingAddress->getPostalCode()); + static::assertSame('12345', $billingAddress->getPostalCode()); + + static::assertSame('Mainstreet 1', $shippingAddress->getAddressLine1()); + static::assertSame('Mainstreet 1', $billingAddress->getAddressLine1()); + + static::assertSame('City 1', $shippingAddress->getAdminArea2()); + static::assertSame('City 1', $billingAddress->getAdminArea2()); + } + + public function testConvertToValidationIssuesNoIssues(): void + { + $transformer = new PayPalCartTransformer( + $this->createMock(EntityRepository::class), + $this->createMock(EntityRepository::class), + $this->createMock(AbstractShippingMethodRoute::class), + $this->createMock(ValidationIssues::class), + ); + + ['validationIssues' => $issues, 'status' => $status] = $transformer->convertToValidationIssues( + new Cart(Uuid::randomHex()), + new CartItemCollection(), + $this->createMock(SalesChannelContext::class) + ); + + static::assertCount(0, $issues); + static::assertSame(PayPalCart::VALIDATION_STATUS__VALID, $status); + } + + public function testConvertToValidationIssues(): void + { + $validationIssueMock = $this->createMock(ValidationIssues::class); + $validationIssueMock + ->method('cartError') + ->willReturnCallback(function (Error $error) { + // Only blocking order errors should be added + static::assertTrue($error->blockOrder()); + + $issue = new ValidationIssue(); + $issue->setMessage($error::class); + + return $issue; + }); + $validationIssueMock + ->method('outOfStock') + ->willReturnCallback(function (LineItem $lineItem) { + $issue = new ValidationIssue(); + $issue->setItemId($lineItem->getReferencedId()); + $issue->setMessage('outOfStock'); + + return $issue; + }); + $validationIssueMock + ->method('changedPrice') + ->willReturnCallback(function (LineItem $lineItem) { + $issue = new ValidationIssue(); + $issue->setItemId($lineItem->getReferencedId()); + $issue->setMessage('changedPrice'); + + return $issue; + }); + + $transformer = new PayPalCartTransformer( + $this->createMock(EntityRepository::class), + $this->createMock(EntityRepository::class), + $this->createMock(AbstractShippingMethodRoute::class), + $validationIssueMock, + ); + + $context = $this->createMock(SalesChannelContext::class); + $context + ->method('getCurrency') + ->willReturn(new CurrencyEntity()); + $context + ->method('getContext') + ->willReturn(Context::createCLIContext()); + $context + ->method('getLanguageInfo') + ->willReturn(new LanguageInfo('Test', 'en-GB')); + + $outOfStockId = Uuid::randomHex(); + $priceChangedId = Uuid::randomHex(); + $validItemId = Uuid::randomHex(); + $validItemWithInitPriceId = Uuid::randomHex(); + $validItemNoInitPriceId = Uuid::randomHex(); + + $outOfStock = new LineItem($outOfStockId, 'product', $outOfStockId, 10); + $outOfStock->setPrice(new CalculatedPrice(100, 1000, new CalculatedTaxCollection(), new TaxRuleCollection())); + $outOfStock->setPayloadValue('stock', 5); + + $priceChanged = new LineItem($priceChangedId, 'product', $priceChangedId, 10); + $priceChanged->setPrice(new CalculatedPrice(100, 1000, new CalculatedTaxCollection(), new TaxRuleCollection())); + + $validItem = new LineItem($validItemId, 'product', $validItemId, 10); + $validItem->setPrice(new CalculatedPrice(100, 1000, new CalculatedTaxCollection(), new TaxRuleCollection())); + $validItem->setPayloadValue('stock', 50); + + $validItemWithInitPrice = new LineItem($validItemWithInitPriceId, 'product', $validItemWithInitPriceId, 10); + $validItemWithInitPrice->setPrice(new CalculatedPrice(100, 1000, new CalculatedTaxCollection(), new TaxRuleCollection())); + $validItemWithInitPrice->setPayloadValue('stock', 50); + + $validItemNoInitPrice = new LineItem($validItemNoInitPriceId, 'product', $validItemNoInitPriceId, 10); + $validItemNoInitPrice->setPrice(new CalculatedPrice(100, 1000, new CalculatedTaxCollection(), new TaxRuleCollection())); + $validItemNoInitPrice->setPayloadValue('stock', 50); + + $invalidReferenceUuid = new LineItem(Uuid::randomHex(), 'product', 'some-string', 1); + $referenceIdNull = new LineItem(Uuid::randomHex(), 'product', null, 1); + + $cart = new Cart(Uuid::randomHex()); + $cart->addLineItems(new LineItemCollection([$outOfStock, $priceChanged, $validItem, $validItemWithInitPrice, $validItemNoInitPrice, $invalidReferenceUuid, $referenceIdNull])); + $cart->addErrors( + new ProductNotFoundError(Uuid::randomHex()), + new PromotionCartAddedInformationError(new LineItem(Uuid::randomHex(), 'promotion')), + new PurchaseStepsError(Uuid::randomHex(), 'Name', 2), + ); + + $money1 = new Money(); + $money1->setValue('100'); + $cartItem1 = new CartItem(); + $cartItem1->setVariantId($validItemWithInitPriceId); + $cartItem1->setPrice($money1); + + $money2 = new Money(); + $money2->setValue('50'); + $cartItem2 = new CartItem(); + $cartItem2->setVariantId($priceChangedId); + $cartItem2->setPrice($money2); + + $cartItem3 = new CartItem(); + $cartItem3->setVariantId($validItemNoInitPriceId); + + $cartItems = new CartItemCollection([$cartItem1, $cartItem2, $cartItem3]); + + ['validationIssues' => $issues, 'status' => $status] = $transformer->convertToValidationIssues($cart, $cartItems, $context); + + static::assertCount(4, $issues); + static::assertSame(PayPalCart::VALIDATION_STATUS__INVALID, $status); + + $productNotFoundIssue = $issues->get(0); + $purchaseStepsIssue = $issues->get(1); + $outOfStockIssue = $issues->get(2); + $changedPriceIssue = $issues->get(3); + + static::assertInstanceOf(ValidationIssue::class, $productNotFoundIssue); + static::assertInstanceOf(ValidationIssue::class, $purchaseStepsIssue); + static::assertInstanceOf(ValidationIssue::class, $outOfStockIssue); + static::assertInstanceOf(ValidationIssue::class, $changedPriceIssue); + + static::assertSame(ProductNotFoundError::class, $productNotFoundIssue->getMessage()); + static::assertSame(PurchaseStepsError::class, $purchaseStepsIssue->getMessage()); + static::assertSame('outOfStock', $outOfStockIssue->getMessage()); + static::assertSame($outOfStockId, $outOfStockIssue->getItemId()); + static::assertSame('changedPrice', $changedPriceIssue->getMessage()); + static::assertSame($priceChangedId, $changedPriceIssue->getItemId()); + } + + public function testConvertToAppliedCouponsNoCoupons(): void + { + $transformer = new PayPalCartTransformer( + $this->createMock(EntityRepository::class), + $this->createMock(EntityRepository::class), + $this->createMock(AbstractShippingMethodRoute::class), + $this->createMock(ValidationIssues::class), + ); + + $currency = new CurrencyEntity(); + $currency->setIsoCode('EUR'); + + $context = $this->createMock(SalesChannelContext::class); + $context + ->method('getCurrency') + ->willReturn($currency); + + $lineItem1 = new LineItem(Uuid::randomHex(), 'promotion'); + $lineItem1->setPrice(new CalculatedPrice(-10, -10, new CalculatedTaxCollection(), new TaxRuleCollection())); + $lineItem1->setPayloadValue('code', 'some-code'); + $lineItem1->setDescription('Code description'); + + $lineItem2 = new LineItem(Uuid::randomHex(), 'promotion'); + $lineItem2->setPayloadValue('code', 'no-price-code'); + $lineItem2->setDescription('Code description'); + + $coupons = $transformer->convertToAppliedCoupons([$lineItem1, $lineItem2], $context); + static::assertInstanceOf(AppliedCouponCollection::class, $coupons); + static::assertCount(1, $coupons); + + $coupon = $coupons->first(); + static::assertInstanceOf(AppliedCoupon::class, $coupon); + static::assertSame('some-code', $coupon->getCode()); + static::assertSame('Code description', $coupon->getDescription()); + static::assertSame('-10', $coupon->getDiscountAmount()?->getValue()); + static::assertSame('EUR', $coupon->getDiscountAmount()->getCurrencyCode()); + } + + private function createCart(): Cart + { + $calculatedTaxes = new CalculatedTaxCollection(); + $calculatedTaxes->add(new CalculatedTax(19, 19, 100)); + $calculatedTaxes->add(new CalculatedTax(7, 7, 100)); + + $cartPrice = new CartPrice( + 200, + 226, + 185, + $calculatedTaxes, + new TaxRuleCollection(), + CartPrice::TAX_STATE_GROSS + ); + + $delivery1 = new Delivery( + new DeliveryPositionCollection(), + new DeliveryDate(new \DateTime(), new \DateTime()), + new ShippingMethodEntity(), + new ShippingLocation(new CountryEntity()), + new CalculatedPrice(10, 10, new CalculatedTaxCollection(), new TaxRuleCollection()) + ); + $delivery2 = new Delivery( + new DeliveryPositionCollection(), + new DeliveryDate(new \DateTime(), new \DateTime()), + new ShippingMethodEntity(), + new ShippingLocation(new CountryEntity()), + new CalculatedPrice(5, 5, new CalculatedTaxCollection(), new TaxRuleCollection()) + ); + + $deliveries = new DeliveryCollection(); + $deliveries->add($delivery1); + $deliveries->add($delivery2); + + $cart = new Cart(Uuid::randomHex()); + $cart->setPrice($cartPrice); + $cart->setDeliveries($deliveries); + + // TODO: discounts need to be tested + + return $cart; + } + + private static function createCustomer(?string $phoneNumber): CustomerEntity + { + $customer = new CustomerEntity(); + $customer->setFirstName('Max'); + $customer->setLastName('Mustermann'); + $customer->setEmail('mail@example.com'); + + if ($phoneNumber) { + $address = new CustomerAddressEntity(); + $address->setPhoneNumber($phoneNumber); + $customer->setDefaultShippingAddress($address); + } + + return $customer; + } +} diff --git a/tests/AgentCommerce/Util/ShopwareCartTransformerTest.php b/tests/AgentCommerce/Util/ShopwareCartTransformerTest.php new file mode 100644 index 000000000..aad156f0a --- /dev/null +++ b/tests/AgentCommerce/Util/ShopwareCartTransformerTest.php @@ -0,0 +1,169 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\Tests\AgentCommerce\Util; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Shopware\Core\Checkout\Cart\LineItem\LineItem; +use Shopware\Core\Checkout\Cart\LineItemFactoryHandler\ProductLineItemFactory; +use Shopware\Core\Checkout\Promotion\Cart\PromotionItemBuilder; +use Shopware\Core\Framework\Context; +use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository; +use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria; +use Shopware\Core\Framework\DataAbstractionLayer\Search\IdSearchResult; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Uuid\Uuid; +use Shopware\Core\System\SalesChannel\SalesChannelContext; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\Coupon; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\PayPalCart; +use Swag\PayPal\AgentCommerce\Exception\AgentException; +use Swag\PayPal\AgentCommerce\Util\ShopwareCartTransformer; + +/** + * @internal + */ +#[Package('checkout')] +#[CoversClass(ShopwareCartTransformer::class)] +class ShopwareCartTransformerTest extends TestCase +{ + public function testExtractCustomerData(): void + { + $overallRandomId = Uuid::randomHex(); + $idResult = new IdSearchResult(1, [['primaryKey' => $overallRandomId]], new Criteria(), Context::createDefaultContext()); + + $repository = $this->createMock(EntityRepository::class); + $repository + ->method('searchIds') + ->willReturn($idResult); + + $transformer = new ShopwareCartTransformer( + $repository, + $repository, + $repository, + $this->createMock(ProductLineItemFactory::class), + $this->createMock(PromotionItemBuilder::class), + ); + + $customerData = $transformer->extractCustomerData((new PayPalCart())->assign(self::requestCustomerData()), $overallRandomId, Context::createDefaultContext()); + + static::assertSame('John', $customerData['firstName']); + static::assertSame('Smith', $customerData['lastName']); + static::assertSame('john.smith@example.com', $customerData['email']); + static::assertSame('john.smith@example.com', $customerData['email']); + static::assertSame($overallRandomId, $customerData['salesChannelId']); + static::assertSame($overallRandomId, $customerData['groupId']); + static::assertSame($overallRandomId, $customerData['shippingAddress']['salutationId']); + static::assertSame($overallRandomId, $customerData['shippingAddress']['countryId']); + static::assertSame('John', $customerData['shippingAddress']['firstName']); + static::assertSame('Smith', $customerData['shippingAddress']['lastName']); + static::assertSame('12345', $customerData['shippingAddress']['zipcode']); + static::assertSame('San Jose', $customerData['shippingAddress']['city']); + static::assertSame('123 Main Street', $customerData['shippingAddress']['street']); + static::assertSame('+1 12345-6789', $customerData['shippingAddress']['phoneNumber']); + static::assertArrayHasKey('billingAddress', $customerData); + static::assertSame('John', $customerData['billingAddress']['firstName']); + static::assertSame('Smith', $customerData['billingAddress']['lastName']); + static::assertSame('10001', $customerData['billingAddress']['zipcode']); + static::assertSame('New York', $customerData['billingAddress']['city']); + static::assertSame('456 Payment Boulevard', $customerData['billingAddress']['street']); + static::assertSame('+1 12345-6789', $customerData['billingAddress']['phoneNumber']); + static::assertTrue($customerData['guest']); + } + + public function testExtractCustomerDataNoCountryCodeFound(): void + { + $exception = AgentException::requiredFieldInvalid('address.countryCode', 'Country not found'); + + $this->expectException($exception::class); + $this->expectExceptionMessage($exception->getMessage()); + + $transformer = new ShopwareCartTransformer( + $this->createMock(EntityRepository::class), + $this->createMock(EntityRepository::class), + $this->createMock(EntityRepository::class), + $this->createMock(ProductLineItemFactory::class), + $this->createMock(PromotionItemBuilder::class), + ); + + $transformer->extractCustomerData((new PayPalCart())->assign(self::requestCustomerData()), Uuid::randomHex(), Context::createDefaultContext()); + } + + public function testGetLineItems(): void + { + $itemId = Uuid::randomHex(); + $itemFactory = $this->createMock(ProductLineItemFactory::class); + $itemFactory + ->method('create') + ->with(['id' => $itemId, 'quantity' => 2]) + ->willReturn(new LineItem($itemId, LineItem::PRODUCT_LINE_ITEM_TYPE, $itemId, 2)); + + $transformer = new ShopwareCartTransformer( + $this->createMock(EntityRepository::class), + $this->createMock(EntityRepository::class), + $this->createMock(EntityRepository::class), + $itemFactory, + new PromotionItemBuilder(), + ); + + $data = [ + 'items' => [ + ['variant_id' => $itemId, 'quantity' => 2], + ], + 'coupons' => [ + ['action' => Coupon::APPLY, 'code' => 'some-code'], + ], + ]; + + $lineItems = $transformer->getLineItems((new PayPalCart())->assign($data), $this->createMock(SalesChannelContext::class)); + static::assertCount(2, $lineItems); + + static::assertSame($itemId, $lineItems[0]->getId()); + static::assertSame(2, $lineItems[0]->getQuantity()); + static::assertSame(LineItem::PRODUCT_LINE_ITEM_TYPE, $lineItems[0]->getType()); + + $key = PromotionItemBuilder::PLACEHOLDER_PREFIX . 'some-code'; + static::assertSame(Uuid::fromStringToHex($key), $lineItems[1]->getId()); + static::assertSame('some-code', $lineItems[1]->getReferencedId()); + static::assertSame(1, $lineItems[1]->getQuantity()); + static::assertSame(LineItem::PROMOTION_LINE_ITEM_TYPE, $lineItems[1]->getType()); + } + + private static function requestCustomerData(): array + { + return [ + 'customer' => [ + 'name' => [ + 'given_name' => 'John', + 'surname' => 'Smith', + ], + 'email_address' => 'john.smith@example.com', + 'phone' => [ + 'country_code' => '1', + 'national_number' => '12345', + 'extension_number' => '6789', + ], + ], + 'shipping_address' => [ + 'address_line_1' => '123 Main Street', + 'address_line_2' => 'Apt 4B', + 'admin_area_2' => 'San Jose', + 'admin_area_1' => 'CA', + 'postal_code' => '12345', + 'country_code' => 'US', + ], + 'billing_address' => [ + 'address_line_1' => '456 Payment Boulevard', + 'address_line_2' => 'Suite 789', + 'admin_area_2' => 'New York', + 'admin_area_1' => 'NY', + 'postal_code' => '10001', + 'country_code' => 'US', + ], + ]; + } +} diff --git a/tests/AgentCommerce/Validation/CartTokenValidatorTest.php b/tests/AgentCommerce/Validation/CartTokenValidatorTest.php new file mode 100644 index 000000000..cb035f270 --- /dev/null +++ b/tests/AgentCommerce/Validation/CartTokenValidatorTest.php @@ -0,0 +1,49 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\Tests\AgentCommerce\Validation; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Shopware\Core\Framework\Log\Package; +use Swag\PayPal\AgentCommerce\Exception\AgentException; +use Swag\PayPal\AgentCommerce\Validation\CartTokenValidator; + +/** + * @internal + */ +#[Package('checkout')] +#[CoversClass(CartTokenValidator::class)] +class CartTokenValidatorTest extends TestCase +{ + #[DataProvider('dataProviderCartToken')] + public function testCartToken(string $token, ?string $id): void + { + if (!$id) { + $this->expectException(AgentException::class); + $this->expectExceptionMessage('Cart ID format is invalid. Expected format: CART-[a-zA-Z0-9]{32}'); + } + + $extracted = CartTokenValidator::validateCartToken($token); + + static::assertSame($id, $extracted); + } + + public static function dataProviderCartToken(): array + { + return [ + ['Cart-123456789', null], + ['CART_123456789', null], + ['CART1234567890', null], + ['1234-CART-1234', null], + ['CART-.,:', null], + ['CART-123456789', '123456789'], + ['CART-ABC123abc', 'ABC123abc'], + ]; + } +} diff --git a/tests/AgentCommerce/Validation/ValidationIssuesTest.php b/tests/AgentCommerce/Validation/ValidationIssuesTest.php new file mode 100644 index 000000000..23cfb1f84 --- /dev/null +++ b/tests/AgentCommerce/Validation/ValidationIssuesTest.php @@ -0,0 +1,291 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\Tests\AgentCommerce\Validation; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Shopware\Core\Checkout\Cart\Address\Error\AddressValidationError; +use Shopware\Core\Checkout\Cart\Address\Error\BillingAddressBlockedError; +use Shopware\Core\Checkout\Cart\Address\Error\BillingAddressCountryRegionMissingError; +use Shopware\Core\Checkout\Cart\Address\Error\BillingAddressSalutationMissingError; +use Shopware\Core\Checkout\Cart\Address\Error\ShippingAddressBlockedError; +use Shopware\Core\Checkout\Cart\Address\Error\ShippingAddressCountryRegionMissingError; +use Shopware\Core\Checkout\Cart\Address\Error\ShippingAddressSalutationMissingError; +use Shopware\Core\Checkout\Cart\Error\Error; +use Shopware\Core\Checkout\Cart\Error\GenericCartError; +use Shopware\Core\Checkout\Cart\Error\IncompleteLineItemError; +use Shopware\Core\Checkout\Cart\LineItem\LineItem; +use Shopware\Core\Checkout\Cart\Price\Struct\CalculatedPrice; +use Shopware\Core\Checkout\Cart\Tax\Struct\CalculatedTaxCollection; +use Shopware\Core\Checkout\Cart\Tax\Struct\TaxRuleCollection; +use Shopware\Core\Checkout\Customer\Aggregate\CustomerAddress\CustomerAddressEntity; +use Shopware\Core\Checkout\Gateway\Error\CheckoutGatewayError; +use Shopware\Core\Checkout\Payment\Cart\Error\PaymentMethodBlockedError; +use Shopware\Core\Checkout\Promotion\Cart\Error\AutoPromotionNotFoundError; +use Shopware\Core\Checkout\Promotion\Cart\Error\PromotionExcludedError; +use Shopware\Core\Checkout\Promotion\Cart\Error\PromotionNotEligibleError; +use Shopware\Core\Checkout\Promotion\Cart\Error\PromotionNotFoundError; +use Shopware\Core\Checkout\Promotion\Cart\Error\PromotionsOnCartPriceZeroError; +use Shopware\Core\Checkout\Promotion\Cart\PromotionCartAddedInformationError; +use Shopware\Core\Checkout\Promotion\Cart\PromotionCartDeletedInformationError; +use Shopware\Core\Checkout\Shipping\Cart\Error\ShippingMethodBlockedError; +use Shopware\Core\Content\Product\Cart\MinOrderQuantityError; +use Shopware\Core\Content\Product\Cart\ProductNotFoundError; +use Shopware\Core\Content\Product\Cart\ProductOutOfStockError; +use Shopware\Core\Content\Product\Cart\ProductStockReachedError; +use Shopware\Core\Content\Product\Cart\PurchaseStepsError; +use Shopware\Core\Content\Product\ProductEntity; +use Shopware\Core\Framework\Adapter\Translation\AbstractTranslator; +use Shopware\Core\Framework\DataAbstractionLayer\Pricing\CashRoundingConfig; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Uuid\Uuid; +use Shopware\Core\System\Currency\CurrencyEntity; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\Context\InventoryIssueContext; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\Context\PricingErrorContext; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\Referral\MetaData; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\ResolutionOption; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\ValidationIssue; +use Shopware\Storefront\Checkout\Cart\Error\PaymentMethodChangedError; +use Shopware\Storefront\Checkout\Cart\Error\ShippingMethodChangedError; +use Swag\PayPal\AgentCommerce\Validation\ValidationIssues; +use Symfony\Component\Validator\ConstraintViolationList; + +/** + * @internal + */ +#[Package('checkout')] +#[CoversClass(ValidationIssues::class)] +class ValidationIssuesTest extends TestCase +{ + #[DataProvider('dataProviderOutOfStock')] + public function testInsufficientInventory(int $stock, bool $isRestock): void + { + $translator = $this->createMock(AbstractTranslator::class); + $translator + ->method('trans') + ->willReturnArgument(0); + + $validation = new ValidationIssues($translator); + + $product = null; + if ($isRestock) { + $product = new ProductEntity(); + $product->setRestockTime(10); + } + + $currency = new CurrencyEntity(); + $currency->setSymbol('€'); + + $uuid = Uuid::randomHex(); + $lineItem = new LineItem($uuid, 'product', $uuid, 10); + $lineItem->setPrice(new CalculatedPrice(100, 1000, new CalculatedTaxCollection(), new TaxRuleCollection())); + $lineItem->setPayloadValue('stock', $stock); + + $validationIssue = $validation->outOfStock($lineItem, $product, $currency); + + static::assertSame(ValidationIssue::CODE__INVENTORY_ISSUE, $validationIssue->getCode()); + static::assertSame(ValidationIssue::TYPE__BUSINESS_RULE, $validationIssue->getType()); + static::assertSame($uuid, $validationIssue->getItemId()); + + static::assertSame('Product stock insufficient', $validationIssue->getMessage()); + static::assertStringContainsString('validationIssue.userMessage.outOfStock', $validationIssue->getUserMessage() ?? ''); + + $context = $validationIssue->getContext(); + static::assertInstanceOf(InventoryIssueContext::class, $context); + static::assertSame(max($stock, 0), $context->getAvailableQuantity()); + static::assertSame(10, $context->getRequestedQuantity()); + + if ($stock > 0) { + static::assertSame(InventoryIssueContext::ISSUE__INSUFFICIENT_INVENTORY, $context->getSpecificIssue()); + } else { + static::assertSame(InventoryIssueContext::ISSUE__ITEM_OUT_OF_STOCK, $context->getSpecificIssue()); + } + + $options = $validationIssue->getResolutionOptions(); + $remove = $options->first(); + + static::assertInstanceOf(ResolutionOption::class, $remove); + static::assertSame(ResolutionOption::ACTION__REMOVE_ITEM, $remove->getAction()); + static::assertStringContainsString('validationIssue.resolutionOption.removeLabel', $remove->getLabel()); + static::assertSame('-€1000', $remove->getMetadata()->getCostImpact()); + static::assertSame(MetaData::PRIORITY__LOW, $remove->getMetadata()->getPriority()); + + if ($isRestock) { + static::assertSame(\date('Y-m-d\T00:00:00', (int) strtotime('+10 days')), $context->getRestockDate()); + + $wait = $options->get(1); + + static::assertInstanceOf(ResolutionOption::class, $wait); + static::assertSame(ResolutionOption::ACTION__WAIT_FOR_RESTOCK, $wait->getAction()); + static::assertStringContainsString('validationIssue.resolutionOption.waitRestockLabel', $wait->getLabel()); + static::assertStringContainsString('validationIssue.resolutionOption.estimatedTime', $wait->getMetadata()->getEstimatedTime()); + static::assertSame(MetaData::PRIORITY__MEDIUM, $wait->getMetadata()->getPriority()); + } + } + + public static function dataProviderOutOfStock(): array + { + return [ + 'Insufficient stock with restock' => [5, true], + 'Insufficient stock without restock' => [5, false], + 'Out of stock with restock' => [0, true], + 'Out of stock without restock' => [-1, false], + ]; + } + + /** + * @param numeric-string $initPrice + */ + #[DataProvider('dataProviderChangedPrice')] + public function testChangedPrice(string $initPrice, bool $exception): void + { + if ($exception) { + // TODO: create real exception + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Init price need to be lower then actual price'); + } + + $translator = $this->createMock(AbstractTranslator::class); + $translator + ->method('trans') + ->willReturnArgument(0); + + $validation = new ValidationIssues($translator); + + $currency = new CurrencyEntity(); + $currency->setSymbol('€'); + $currency->setIsoCode('EUR'); + + $price = 100; + $diff = (string) ($price - (float) $initPrice); + $uuid = Uuid::randomHex(); + $lineItem = new LineItem($uuid, 'product', $uuid, 10); + $lineItem->setPrice(new CalculatedPrice($price, $price * 10, new CalculatedTaxCollection(), new TaxRuleCollection())); + + $validationIssue = $validation->changedPrice($lineItem, $initPrice, $currency, new CashRoundingConfig(2, 2, false)); + + static::assertSame(ValidationIssue::CODE__PRICING_ERROR, $validationIssue->getCode()); + static::assertSame(ValidationIssue::TYPE__BUSINESS_RULE, $validationIssue->getType()); + static::assertSame($uuid, $validationIssue->getItemId()); + + static::assertSame('Product price has changed', $validationIssue->getMessage()); + static::assertStringContainsString('validationIssue.userMessage.priceChanged', $validationIssue->getUserMessage() ?? ''); + + $context = $validationIssue->getContext(); + static::assertInstanceOf(PricingErrorContext::class, $context); + static::assertSame($initPrice, $context->getOriginalPrice()); + static::assertSame((string) $price, $context->getCurrentPrice()); + static::assertSame($diff, $context->getPriceIncrease()); + static::assertSame('EUR', $context->getCurrencyCode()); + static::assertSame('component_cost_increase', $context->getPriceChangeReason()); + + $accept = $validationIssue->getResolutionOptions()->first(); + $remove = $validationIssue->getResolutionOptions()->get(1); + + static::assertInstanceOf(ResolutionOption::class, $accept); + static::assertInstanceOf(ResolutionOption::class, $remove); + + static::assertSame(ResolutionOption::ACTION__ACCEPT_NEW_PRICE, $accept->getAction()); + static::assertSame('+€' . $diff, $accept->getMetadata()->getCostImpact()); + static::assertSame(MetaData::PRIORITY__HIGH, $accept->getMetadata()->getPriority()); + static::assertStringContainsString('validationIssue.resolutionOption.acceptLabel', $accept->getLabel()); + + static::assertSame(ResolutionOption::ACTION__REMOVE_ITEM, $remove->getAction()); + static::assertSame('-€' . $initPrice, $remove->getMetadata()->getCostImpact()); + static::assertSame(MetaData::PRIORITY__MEDIUM, $remove->getMetadata()->getPriority()); + static::assertStringContainsString('validationIssue.resolutionOption.removeLabel', $remove->getLabel()); + } + + /** + * @return array + */ + public static function dataProviderChangedPrice(): array + { + return [ + 'equal price' => ['100', true], + 'lower price' => ['110', true], + 'greater price' => ['80', false], + ]; + } + + #[DataProvider('dataProviderCartError')] + public function testCartError(Error $error, string $code): void + { + $translator = $this->createMock(AbstractTranslator::class); + $translator + ->method('trans') + ->willReturnArgument(0); + + $validation = new ValidationIssues($translator); + + $validationIssue = $validation->cartError($error, 'en-GB'); + + static::assertSame($code, $validationIssue->getCode()); + static::assertSame(ValidationIssue::TYPE__BUSINESS_RULE, $validationIssue->getType()); + } + + /** + * @return iterable, array{0: Error}> + */ + public static function dataProviderCartError(): iterable + { + $code = ValidationIssue::CODE__BUSINESS_RULE_ERROR; + + yield AddressValidationError::class => [new AddressValidationError(true, new ConstraintViolationList()), ValidationIssue::CODE__SHIPPING_ERROR]; + yield BillingAddressBlockedError::class => [new BillingAddressBlockedError('foo'), ValidationIssue::CODE__SHIPPING_ERROR]; + yield BillingAddressCountryRegionMissingError::class => [new BillingAddressCountryRegionMissingError(self::createCustomerAddress()), $code]; + yield BillingAddressSalutationMissingError::class => [new BillingAddressSalutationMissingError(self::createCustomerAddress()), $code]; + yield ShippingAddressBlockedError::class => [new ShippingAddressBlockedError('foo'), ValidationIssue::CODE__SHIPPING_ERROR]; + yield ShippingAddressCountryRegionMissingError::class => [new ShippingAddressCountryRegionMissingError(self::createCustomerAddress()), $code]; + yield ShippingAddressSalutationMissingError::class => [new ShippingAddressSalutationMissingError(self::createCustomerAddress()), $code]; + yield GenericCartError::class => [new GenericCartError('foo', 'bar', [], Error::LEVEL_ERROR, false, false, false), $code]; + yield IncompleteLineItemError::class => [new IncompleteLineItemError('foo', 'bar'), $code]; + yield CheckoutGatewayError::class => [new CheckoutGatewayError('foo', Error::LEVEL_NOTICE, true), $code]; + yield PaymentMethodBlockedError::class => [new PaymentMethodBlockedError('foo', 'reason', Uuid::randomHex()), $code]; // @phpstan-ignore method.deprecated + yield AutoPromotionNotFoundError::class => [new AutoPromotionNotFoundError('foo'), $code]; + yield PromotionExcludedError::class => [new PromotionExcludedError('foo'), $code]; + yield PromotionNotEligibleError::class => [new PromotionNotEligibleError('foo'), $code]; + yield PromotionNotFoundError::class => [new PromotionNotFoundError('foo'), $code]; + yield PromotionsOnCartPriceZeroError::class => [new PromotionsOnCartPriceZeroError(['foo', 'bar']), $code]; + yield PromotionCartAddedInformationError::class => [new PromotionCartAddedInformationError(self::createLineItem()), $code]; + yield PromotionCartDeletedInformationError::class => [new PromotionCartDeletedInformationError(self::createLineItem()), $code]; + yield ShippingMethodBlockedError::class => [new ShippingMethodBlockedError('foo', Uuid::randomHex(), 'reason'), $code]; // @phpstan-ignore method.deprecated + yield MinOrderQuantityError::class => [new MinOrderQuantityError(Uuid::randomHex(), 'foo', 5), ValidationIssue::CODE__INVENTORY_ISSUE]; + yield ProductNotFoundError::class => [new ProductNotFoundError(Uuid::randomHex()), ValidationIssue::CODE__INVENTORY_ISSUE]; + yield ProductOutOfStockError::class => [new ProductOutOfStockError(Uuid::randomHex(), 'foo'), ValidationIssue::CODE__INVENTORY_ISSUE]; + yield ProductStockReachedError::class => [new ProductStockReachedError(Uuid::randomHex(), 'foo', 1), ValidationIssue::CODE__INVENTORY_ISSUE]; + yield PurchaseStepsError::class => [new PurchaseStepsError(Uuid::randomHex(), 'foo', 5), ValidationIssue::CODE__INVENTORY_ISSUE]; + yield PaymentMethodChangedError::class => [new PaymentMethodChangedError('foo', 'bar', Uuid::randomHex(), Uuid::randomHex(), 'reason'), $code]; // @phpstan-ignore method.deprecated + yield ShippingMethodChangedError::class => [new ShippingMethodChangedError('foo', 'bar', Uuid::randomHex(), Uuid::randomHex(), 'reason'), $code]; // @phpstan-ignore method.deprecated + } + + private static function createCustomerAddress(): CustomerAddressEntity + { + $address = new CustomerAddressEntity(); + $address->setId(Uuid::randomHex()); + + $address->setCustomerId(Uuid::randomHex()); + $address->setCountryId(Uuid::randomHex()); + $address->setFirstName('John'); + $address->setLastName('Doe'); + $address->setZipcode('12345'); + $address->setCity('Testcity'); + $address->setStreet('Teststreet 1'); + + return $address; + } + + private static function createLineItem(): LineItem + { + $lineItem = new LineItem(Uuid::randomHex(), LineItem::PRODUCT_LINE_ITEM_TYPE, Uuid::randomHex(), 2); + $lineItem->setLabel('LineItem label'); + + return $lineItem; + } +} diff --git a/tests/Checkout/ExpressCheckout/Service/PayPalExpressCheckoutDataServiceTest.php b/tests/Checkout/ExpressCheckout/Service/PayPalExpressCheckoutDataServiceTest.php index 49f91d2a7..db94786da 100644 --- a/tests/Checkout/ExpressCheckout/Service/PayPalExpressCheckoutDataServiceTest.php +++ b/tests/Checkout/ExpressCheckout/Service/PayPalExpressCheckoutDataServiceTest.php @@ -86,10 +86,10 @@ protected function setUp(): void $this->cartService = $container->get(CartService::class); $this->payLaterMethodData = $container->get(PayLaterMethodData::class); - /** @var RouterInterface $router */ $router = $container->get('router'); + static::assertInstanceOf(RouterInterface::class, $router); - $this->systemConfigService = $this->createDefaultSystemConfig(); + $this->systemConfigService = self::createDefaultSystemConfig(); $this->expressCheckoutDataService = new PayPalExpressCheckoutDataService( $this->cartService, diff --git a/tests/Helper/ServicesTrait.php b/tests/Helper/ServicesTrait.php index def40e3cb..2f51bdefd 100644 --- a/tests/Helper/ServicesTrait.php +++ b/tests/Helper/ServicesTrait.php @@ -71,7 +71,7 @@ protected function createPaymentBuilder(?SystemConfigService $systemConfig = nul protected function createOrderBuilder(?SystemConfigService $systemConfig = null): PayPalOrderBuilder { - $systemConfig = $systemConfig ?? $this->createDefaultSystemConfig(); + $systemConfig = $systemConfig ?? self::createDefaultSystemConfig(); $priceFormatter = new PriceFormatter(); $amountProvider = new AmountProvider($priceFormatter); diff --git a/tests/Migration/Migration1752337399AddAgentCommerceSalesChannelTypeTest.php b/tests/Migration/Migration1752337399AddAgentCommerceSalesChannelTypeTest.php new file mode 100644 index 000000000..22c3d9597 --- /dev/null +++ b/tests/Migration/Migration1752337399AddAgentCommerceSalesChannelTypeTest.php @@ -0,0 +1,60 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\Test\Migration; + +use Doctrine\DBAL\Connection; +use PHPUnit\Framework\TestCase; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Test\TestCaseBase\DatabaseTransactionBehaviour; +use Shopware\Core\Framework\Test\TestCaseBase\KernelTestBehaviour; +use Shopware\Core\Framework\Uuid\Uuid; +use Swag\PayPal\Migration\Migration1752337399AddAgentCommerceSalesChannelType; +use Swag\PayPal\SwagPayPal; + +/** + * @internal + */ +#[Package('checkout')] +class Migration1752337399AddAgentCommerceSalesChannelTypeTest extends TestCase +{ + use DatabaseTransactionBehaviour; + use KernelTestBehaviour; + + public function testMigration(): void + { + $connection = $this->getContainer()->get(Connection::class); + + $this->rollback($connection); + + $migration = new Migration1752337399AddAgentCommerceSalesChannelType(); + $migration->update($connection); + $migration->update($connection); + + $type = $connection->fetchOne( + 'SELECT `id` FROM `sales_channel_type` WHERE `id` = :id', + ['id' => Uuid::fromHexToBytes(SwagPayPal::SALES_CHANNEL_TYPE_AGENT_COMMERCE)] + ); + + static::assertNotFalse($type); + + $translations = $connection->fetchAllAssociative( + 'SELECT `sales_channel_type_id`, `language_id`, `name` FROM `sales_channel_type_translation` WHERE `sales_channel_type_id` = :id', + ['id' => Uuid::fromHexToBytes(SwagPayPal::SALES_CHANNEL_TYPE_AGENT_COMMERCE)] + ); + + static::assertCount(2, $translations); + } + + public function rollback(Connection $connection): void + { + $connection->executeStatement( + 'DELETE FROM `sales_channel_type` WHERE `id` = :id', + ['id' => Uuid::fromHexToBytes(SwagPayPal::SALES_CHANNEL_TYPE_AGENT_COMMERCE)] + ); + } +} diff --git a/tests/OrdersApi/Builder/APMOrderBuilderTest.php b/tests/OrdersApi/Builder/APMOrderBuilderTest.php index 84ba65a97..524b26751 100644 --- a/tests/OrdersApi/Builder/APMOrderBuilderTest.php +++ b/tests/OrdersApi/Builder/APMOrderBuilderTest.php @@ -97,6 +97,8 @@ public function testGetOrder(string $orderBuilderClass, array $requestData, stri $paymentSource = $order->getPaymentSource(); static::assertNotNull($paymentSource); $getter = 'get' . $this->getPropertyName($structClass); + + // @phpstan-ignore method.dynamicName $struct = $paymentSource->{$getter}(); static::assertInstanceOf($structClass, $struct); static::assertSame('DE', $struct->getCountryCode()); @@ -148,6 +150,8 @@ public function testGetOrderNoShippingAddress(string $orderBuilderClass, array $ $paymentSource = $order->getPaymentSource(); static::assertNotNull($paymentSource); $getter = 'get' . $this->getPropertyName($structClass); + + // @phpstan-ignore method.dynamicName $struct = $paymentSource->{$getter}(); static::assertSame(ExperienceContext::SHIPPING_PREFERENCE_NO_SHIPPING, $struct?->getExperienceContext()?->getShippingPreference()); diff --git a/tests/Pos/Run/PosSyncControllerTest.php b/tests/Pos/Run/PosSyncControllerTest.php index 339996b92..ab013ca10 100644 --- a/tests/Pos/Run/PosSyncControllerTest.php +++ b/tests/Pos/Run/PosSyncControllerTest.php @@ -134,6 +134,8 @@ public function testSyncWithInvalidId(string $syncFunction): void { $context = Context::createDefaultContext(); $this->expectException(InvalidSalesChannelIdException::class); + + // @phpstan-ignore method.dynamicName $this->posSyncController->{$syncFunction}(self::INVALID_CHANNEL_ID, $context); } @@ -142,6 +144,8 @@ public function testSyncNormal(string $syncFunction, array $serviceCalls): void { $context = Context::createDefaultContext(); $salesChannelId = $this->salesChannelRepoMock->getMockEntity(); + + // @phpstan-ignore method.dynamicName $this->posSyncController->{$syncFunction}($salesChannelId->getId(), $context); $envelope = \current($this->messageBus->getEnvelopes()); diff --git a/tests/Pos/Sync/CompleteProductTest.php b/tests/Pos/Sync/CompleteProductTest.php index f2707443f..a9ab406b7 100644 --- a/tests/Pos/Sync/CompleteProductTest.php +++ b/tests/Pos/Sync/CompleteProductTest.php @@ -23,7 +23,7 @@ use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Test\TestCaseBase\KernelTestBehaviour; use Shopware\Core\Framework\Uuid\Uuid; -use Shopware\Core\System\SalesChannel\Context\SalesChannelContextFactory; +use Shopware\Core\System\SalesChannel\Context\AbstractSalesChannelContextFactory; use Shopware\Core\System\SalesChannel\Context\SalesChannelContextService; use Shopware\Core\Test\TestDefaults; use Swag\PayPal\Pos\Api\Product; @@ -97,8 +97,8 @@ public function testProductSync(): void $productRepository = new ProductRepoMock(); $salesChannelProductRepository = new SalesChannelProductRepoMock(); - /** @var SalesChannelContextFactory $salesChannelContextFactory */ - $salesChannelContextFactory = $this->getContainer()->get('Shopware\Core\System\SalesChannel\Context\SalesChannelContextFactory'); + $salesChannelContextFactory = static::getContainer()->get('Shopware\Core\System\SalesChannel\Context\SalesChannelContextFactory'); + static::assertInstanceOf(AbstractSalesChannelContextFactory::class, $salesChannelContextFactory); $productConverter = new ProductConverter( new UuidConverter(), @@ -315,7 +315,7 @@ private function getCategory(): CategoryEntity $criteria->setLimit(1); /** @var EntityRepository $categoryRepository */ - $categoryRepository = $this->getContainer()->get('category.repository'); + $categoryRepository = static::getContainer()->get('category.repository'); /** @var CategoryEntity|null $category */ $category = $categoryRepository->search($criteria, Context::createDefaultContext())->first(); diff --git a/tests/Pos/Sync/Inventory/UpdaterTrait.php b/tests/Pos/Sync/Inventory/UpdaterTrait.php index 301b0b797..a17d9cf5c 100644 --- a/tests/Pos/Sync/Inventory/UpdaterTrait.php +++ b/tests/Pos/Sync/Inventory/UpdaterTrait.php @@ -67,7 +67,7 @@ private function createInventoryContext(SalesChannelProductEntity $product, int ]]); $variant = new Variant(); $variant->assign([ - 'productUuid' => $product->getParentId() ? $uuidConverter->convertUuidToV1((string) $product->getParentId()) : '', + 'productUuid' => $product->getParentId() ? $uuidConverter->convertUuidToV1($product->getParentId()) : '', 'variantUuid' => $uuidConverter->convertUuidToV1($product->getId()), 'balance' => (string) $posStock, ]); diff --git a/tests/Pos/Webhook/InventoryChangedTest.php b/tests/Pos/Webhook/InventoryChangedTest.php index bdf641c16..e3ff80927 100644 --- a/tests/Pos/Webhook/InventoryChangedTest.php +++ b/tests/Pos/Webhook/InventoryChangedTest.php @@ -41,7 +41,7 @@ use Swag\PayPal\Test\Pos\Mock\RunServiceMock; use Swag\PayPal\Test\Pos\Webhook\_fixtures\InventoryChangeFixture; use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\Routing\Router; +use Symfony\Component\Routing\RouterInterface; /** * @internal @@ -109,14 +109,14 @@ public function testInventoryChanged(): void $salesChannelRepository = new SalesChannelRepoMock(); $salesChannelRepository->addMockEntity($salesChannel); - /** @var Router $router */ - $router = $this->getContainer()->get('router'); + $router = static::getContainer()->get('router'); + static::assertInstanceOf(RouterInterface::class, $router); $webhookService = new WebhookService( new SubscriptionResource(new PosClientFactoryMock()), $webhookRegistry, $salesChannelRepository, - $this->getContainer()->get(SystemConfigService::class), + static::getContainer()->get(SystemConfigService::class), new UuidConverter(), $router ); @@ -177,14 +177,14 @@ public function testDisabledInventoryStockManagement(): void $salesChannelRepository = new SalesChannelRepoMock(); $salesChannelRepository->addMockEntity($salesChannel); - /** @var Router $router */ - $router = $this->getContainer()->get('router'); + $router = self::getContainer()->get('router'); + static::assertInstanceOf(RouterInterface::class, $router); $webhookService = new WebhookService( new SubscriptionResource(new PosClientFactoryMock()), $webhookRegistry, $salesChannelRepository, - $this->getContainer()->get(SystemConfigService::class), + self::getContainer()->get(SystemConfigService::class), new UuidConverter(), $router ); diff --git a/tests/Pos/Webhook/WebhookControllerTest.php b/tests/Pos/Webhook/WebhookControllerTest.php index b02cedcc3..8aa4f6b17 100644 --- a/tests/Pos/Webhook/WebhookControllerTest.php +++ b/tests/Pos/Webhook/WebhookControllerTest.php @@ -37,7 +37,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; -use Symfony\Component\Routing\Router; +use Symfony\Component\Routing\RouterInterface; /** * @internal @@ -69,14 +69,14 @@ protected function setUp(): void $salesChannelRepository = new SalesChannelRepoMock(); $salesChannelRepository->addMockEntity($this->salesChannel); - /** @var Router $router */ - $router = $this->getContainer()->get('router'); + $router = self::getContainer()->get('router'); + static::assertInstanceOf(RouterInterface::class, $router); $webhookService = new WebhookService( new SubscriptionResource(new PosClientFactoryMock()), $webhookRegistry, $salesChannelRepository, - $this->getContainer()->get(SystemConfigService::class), + self::getContainer()->get(SystemConfigService::class), new UuidConverter(), $router ); diff --git a/tests/Storefront/Data/CheckoutSubscriberTest.php b/tests/Storefront/Data/CheckoutSubscriberTest.php index bfd388e38..37f2145a7 100644 --- a/tests/Storefront/Data/CheckoutSubscriberTest.php +++ b/tests/Storefront/Data/CheckoutSubscriberTest.php @@ -111,6 +111,7 @@ public function testOnAccountOrderEditSPBDisabled(string $paymentMethodId, strin if ($extensionId === PayPalMethodData::PAYPAL_SMART_PAYMENT_BUTTONS_DATA_EXTENSION_ID) { static::assertFalse($event->getPage()->hasExtension($extensionId)); } else { + // @phpstan-ignore method.dynamicName $this->{$assertionMethod}($event, $paymentMethodId, $extensionId); } } @@ -147,6 +148,8 @@ public function testOnAccountOrderEditLoaded(string $paymentMethodId, string $ex $subscriber = $this->createSubscriber(); $event = $this->createEditOrderPageLoadedEvent($paymentMethodId); $subscriber->onAccountOrderEditLoaded($event); + + // @phpstan-ignore method.dynamicName $this->{$assertionMethod}($event, $paymentMethodId, $extensionId); } @@ -164,6 +167,7 @@ public function testOnCheckoutConfirmSPBDisabled(string $paymentMethodId, string if ($extensionId === PayPalMethodData::PAYPAL_SMART_PAYMENT_BUTTONS_DATA_EXTENSION_ID) { static::assertFalse($event->getPage()->hasExtension($extensionId)); } else { + // @phpstan-ignore method.dynamicName $this->{$assertionMethod}($event, $paymentMethodId, $extensionId); } } @@ -201,6 +205,7 @@ public function testOnCheckoutConfirmLoaded(string $paymentMethodId, string $ext $event = $this->createConfirmPageLoadedEvent($paymentMethodId); $subscriber->onCheckoutConfirmLoaded($event); + // @phpstan-ignore method.dynamicName $this->{$assertionMethod}($event, $paymentMethodId, $extensionId); } @@ -291,7 +296,7 @@ public static function dataProviderPaymentMethods(): iterable */ private function createSubscriber(array $settingsOverride = []): CheckoutDataSubscriber { - $settings = $this->createSystemConfigServiceMock(\array_merge([ + $settings = self::createSystemConfigServiceMock(\array_merge([ Settings::CLIENT_ID => self::TEST_CLIENT_ID, Settings::CLIENT_SECRET => 'testClientSecret', Settings::SPB_CHECKOUT_ENABLED => true, @@ -301,8 +306,9 @@ private function createSubscriber(array $settingsOverride = []): CheckoutDataSub $credentialsUtil = new CredentialsUtil($settings); $localeCodeProvider = $this->getContainer()->get(LocaleCodeProvider::class); - /** @var RouterInterface $router */ $router = $this->getContainer()->get('router'); + static::assertInstanceOf(RouterInterface::class, $router); + $sepaDataService = new SEPACheckoutDataService( $this->paymentMethodDataRegistry, $localeCodeProvider, diff --git a/tests/Util/Lifecycle/UpdateTest.php b/tests/Util/Lifecycle/UpdateTest.php index d3f33f90a..39d6d64bc 100644 --- a/tests/Util/Lifecycle/UpdateTest.php +++ b/tests/Util/Lifecycle/UpdateTest.php @@ -69,7 +69,6 @@ use Swag\PayPal\Webhook\WebhookService; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; -use Symfony\Component\Routing\Router; use Symfony\Component\Routing\RouterInterface; /** @@ -429,8 +428,10 @@ private function createUpdateService( ?WebhookService $webhookService = null, ?PosWebhookService $posWebhookService = null, ): Update { - /** @var InformationDefaultService|null $informationDefaultService */ $informationDefaultService = $this->getContainer()->get(InformationDefaultService::class); + + static::assertInstanceOf(InformationDefaultService::class, $informationDefaultService); + $paymentMethodDataRegistry = new PaymentMethodDataRegistry($this->paymentMethodRepository, $this->getContainer()); return new Update( @@ -476,9 +477,10 @@ private function createWebhookService(SystemConfigService $systemConfigService): private function createPosWebhookService(SystemConfigService $systemConfigService): PosWebhookService { $webhookRegistry = new PosWebhookRegistry(new \ArrayObject([])); - /** @var Router $router */ $router = $this->getContainer()->get('router'); + static::assertInstanceOf(RouterInterface::class, $router); + return new PosWebhookService( new SubscriptionResource(new PosClientFactoryMock()), $webhookRegistry, From 150464a1bcd32ed8f41e557dde2da8dbaa983df9 Mon Sep 17 00:00:00 2001 From: Fabian Boensch Date: Tue, 28 Oct 2025 08:23:14 +0100 Subject: [PATCH 02/23] Creating sdk structs --- .../Exception/AgentHttpException.php | 32 ++ .../Routing/AgentContextResolverListener.php | 44 ++ .../AgentResponseExceptionSubscriber.php | 109 +++++ src/AgentCommerce/Struct/V1/Address.php | 154 +++++++ src/AgentCommerce/Struct/V1/AgentError.php | 78 ++++ .../Struct/V1/AgentErrorDetail.php | 54 +++ .../Struct/V1/AgentErrorDetailCollection.php | 25 ++ src/AgentCommerce/Struct/V1/AppliedCoupon.php | 64 +++ .../Struct/V1/AppliedCouponCollection.php | 25 ++ .../Struct/V1/BillingAddress.php | 50 +++ .../Struct/V1/Builder/MetaDataBuilder.php | 72 ++++ .../Struct/V1/Builder/ResolutionBuilder.php | 62 +++ .../V1/Builder/ValidationIssueBuilder.php | 90 ++++ src/AgentCommerce/Struct/V1/CartItem.php | 194 +++++++++ .../Struct/V1/CartItemCollection.php | 25 ++ src/AgentCommerce/Struct/V1/CartTotals.php | 168 ++++++++ src/AgentCommerce/Struct/V1/CheckoutField.php | 220 ++++++++++ .../Struct/V1/CheckoutFieldCollection.php | 25 ++ .../Struct/V1/Context/AbstractContext.php | 49 +++ .../V1/Context/BusinessRuleErrorContext.php | 403 +++++++++++++++++ .../Struct/V1/Context/DataErrorContext.php | 268 ++++++++++++ .../V1/Context/InventoryIssueContext.php | 298 +++++++++++++ .../Struct/V1/Context/PaymentErrorContext.php | 270 ++++++++++++ .../Struct/V1/Context/PricingErrorContext.php | 406 ++++++++++++++++++ .../V1/Context/ShippingErrorContext.php | 292 +++++++++++++ src/AgentCommerce/Struct/V1/Coupon.php | 76 ++++ .../Struct/V1/CouponCollection.php | 25 ++ src/AgentCommerce/Struct/V1/Customer.php | 76 ++++ src/AgentCommerce/Struct/V1/Error.php | 92 ++++ .../Struct/V1/GeoCoordinates.php | 106 +++++ src/AgentCommerce/Struct/V1/GiftOptions.php | 132 ++++++ src/AgentCommerce/Struct/V1/Link.php | 132 ++++++ .../Struct/V1/LinkCollection.php | 25 ++ src/AgentCommerce/Struct/V1/Money.php | 77 ++++ src/AgentCommerce/Struct/V1/PayPalCart.php | 304 +++++++++++++ src/AgentCommerce/Struct/V1/PaymentMethod.php | 108 +++++ src/AgentCommerce/Struct/V1/Phone.php | 124 ++++++ .../Struct/V1/Referral/BusinessHour.php | 59 +++ .../V1/Referral/BusinessHourCollection.php | 25 ++ .../Struct/V1/Referral/CustomOption.php | 59 +++ .../V1/Referral/CustomOptionCollection.php | 25 ++ .../Struct/V1/Referral/CustomerName.php | 46 ++ .../Struct/V1/Referral/Measurements.php | 72 ++++ .../Struct/V1/Referral/MetaData.php | 106 +++++ .../Struct/V1/Referral/MetaDataCollection.php | 25 ++ .../Struct/V1/Referral/MixedItem.php | 46 ++ .../V1/Referral/MixedItemCollection.php | 25 ++ .../Struct/V1/Referral/Recipient.php | 59 +++ .../Struct/V1/Referral/SelectedAttribute.php | 46 ++ .../Referral/SelectedAttributeCollection.php | 25 ++ .../V1/Referral/SuggestedCorrection.php | 59 +++ .../SuggestedCorrectionCollection.php | 25 ++ .../Struct/V1/ResolutionOption.php | 153 +++++++ .../Struct/V1/ResolutionOptionCollection.php | 25 ++ .../Struct/V1/ShippingAddress.php | 18 + .../Struct/V1/ShippingOption.php | 124 ++++++ .../Struct/V1/ShippingOptionCollection.php | 25 ++ .../Struct/V1/ValidationIssue.php | 211 +++++++++ .../Struct/V1/ValidationIssueCollection.php | 25 ++ .../Struct/V1/Value/AgeVerificationValue.php | 81 ++++ .../V1/Value/AllergyInformationValue.php | 130 ++++++ .../V1/Value/CustomEngravingTextValue.php | 125 ++++++ .../Struct/V1/Value/CustomSizingInfoValue.php | 76 ++++ .../V1/Value/DeliveryDatePreferenceValue.php | 83 ++++ .../V1/Value/DeliveryInstructionsValue.php | 74 ++++ .../Struct/V1/Value/GiftMessageValue.php | 58 +++ .../V1/Value/GiftRecipientEmailValue.php | 55 +++ .../V1/Value/GiftRecipientNameValue.php | 71 +++ .../Struct/V1/Value/PrivacyConsentValue.php | 113 +++++ .../Struct/V1/Value/TermsAcceptanceValue.php | 87 ++++ .../Util/AgentDebugIDProcessor.php | 76 ++++ src/AgentCommerce/Validation/HasScopes.php | 53 +++ .../sw-sales-channel-create/index.ts | 4 +- .../{body.txt => body.csv.twig} | 0 .../{header.txt => header.csv.twig} | 0 src/Resources/config/services.php | 1 + .../Exception/AgentExceptionTest.php | 131 ++++++ .../Exception/AgentHttpExceptionTest.php | 43 ++ .../Validation/HasScopesTest.php | 139 ++++++ 79 files changed, 7435 insertions(+), 2 deletions(-) create mode 100644 src/AgentCommerce/Exception/AgentHttpException.php create mode 100644 src/AgentCommerce/Routing/AgentContextResolverListener.php create mode 100644 src/AgentCommerce/SalesChannel/Response/AgentResponseExceptionSubscriber.php create mode 100644 src/AgentCommerce/Struct/V1/Address.php create mode 100644 src/AgentCommerce/Struct/V1/AgentError.php create mode 100644 src/AgentCommerce/Struct/V1/AgentErrorDetail.php create mode 100644 src/AgentCommerce/Struct/V1/AgentErrorDetailCollection.php create mode 100644 src/AgentCommerce/Struct/V1/AppliedCoupon.php create mode 100644 src/AgentCommerce/Struct/V1/AppliedCouponCollection.php create mode 100644 src/AgentCommerce/Struct/V1/BillingAddress.php create mode 100644 src/AgentCommerce/Struct/V1/Builder/MetaDataBuilder.php create mode 100644 src/AgentCommerce/Struct/V1/Builder/ResolutionBuilder.php create mode 100644 src/AgentCommerce/Struct/V1/Builder/ValidationIssueBuilder.php create mode 100644 src/AgentCommerce/Struct/V1/CartItem.php create mode 100644 src/AgentCommerce/Struct/V1/CartItemCollection.php create mode 100644 src/AgentCommerce/Struct/V1/CartTotals.php create mode 100644 src/AgentCommerce/Struct/V1/CheckoutField.php create mode 100644 src/AgentCommerce/Struct/V1/CheckoutFieldCollection.php create mode 100644 src/AgentCommerce/Struct/V1/Context/AbstractContext.php create mode 100644 src/AgentCommerce/Struct/V1/Context/BusinessRuleErrorContext.php create mode 100644 src/AgentCommerce/Struct/V1/Context/DataErrorContext.php create mode 100644 src/AgentCommerce/Struct/V1/Context/InventoryIssueContext.php create mode 100644 src/AgentCommerce/Struct/V1/Context/PaymentErrorContext.php create mode 100644 src/AgentCommerce/Struct/V1/Context/PricingErrorContext.php create mode 100644 src/AgentCommerce/Struct/V1/Context/ShippingErrorContext.php create mode 100644 src/AgentCommerce/Struct/V1/Coupon.php create mode 100644 src/AgentCommerce/Struct/V1/CouponCollection.php create mode 100644 src/AgentCommerce/Struct/V1/Customer.php create mode 100644 src/AgentCommerce/Struct/V1/Error.php create mode 100644 src/AgentCommerce/Struct/V1/GeoCoordinates.php create mode 100644 src/AgentCommerce/Struct/V1/GiftOptions.php create mode 100644 src/AgentCommerce/Struct/V1/Link.php create mode 100644 src/AgentCommerce/Struct/V1/LinkCollection.php create mode 100644 src/AgentCommerce/Struct/V1/Money.php create mode 100644 src/AgentCommerce/Struct/V1/PayPalCart.php create mode 100644 src/AgentCommerce/Struct/V1/PaymentMethod.php create mode 100644 src/AgentCommerce/Struct/V1/Phone.php create mode 100644 src/AgentCommerce/Struct/V1/Referral/BusinessHour.php create mode 100644 src/AgentCommerce/Struct/V1/Referral/BusinessHourCollection.php create mode 100644 src/AgentCommerce/Struct/V1/Referral/CustomOption.php create mode 100644 src/AgentCommerce/Struct/V1/Referral/CustomOptionCollection.php create mode 100644 src/AgentCommerce/Struct/V1/Referral/CustomerName.php create mode 100644 src/AgentCommerce/Struct/V1/Referral/Measurements.php create mode 100644 src/AgentCommerce/Struct/V1/Referral/MetaData.php create mode 100644 src/AgentCommerce/Struct/V1/Referral/MetaDataCollection.php create mode 100644 src/AgentCommerce/Struct/V1/Referral/MixedItem.php create mode 100644 src/AgentCommerce/Struct/V1/Referral/MixedItemCollection.php create mode 100644 src/AgentCommerce/Struct/V1/Referral/Recipient.php create mode 100644 src/AgentCommerce/Struct/V1/Referral/SelectedAttribute.php create mode 100644 src/AgentCommerce/Struct/V1/Referral/SelectedAttributeCollection.php create mode 100644 src/AgentCommerce/Struct/V1/Referral/SuggestedCorrection.php create mode 100644 src/AgentCommerce/Struct/V1/Referral/SuggestedCorrectionCollection.php create mode 100644 src/AgentCommerce/Struct/V1/ResolutionOption.php create mode 100644 src/AgentCommerce/Struct/V1/ResolutionOptionCollection.php create mode 100644 src/AgentCommerce/Struct/V1/ShippingAddress.php create mode 100644 src/AgentCommerce/Struct/V1/ShippingOption.php create mode 100644 src/AgentCommerce/Struct/V1/ShippingOptionCollection.php create mode 100644 src/AgentCommerce/Struct/V1/ValidationIssue.php create mode 100644 src/AgentCommerce/Struct/V1/ValidationIssueCollection.php create mode 100644 src/AgentCommerce/Struct/V1/Value/AgeVerificationValue.php create mode 100644 src/AgentCommerce/Struct/V1/Value/AllergyInformationValue.php create mode 100644 src/AgentCommerce/Struct/V1/Value/CustomEngravingTextValue.php create mode 100644 src/AgentCommerce/Struct/V1/Value/CustomSizingInfoValue.php create mode 100644 src/AgentCommerce/Struct/V1/Value/DeliveryDatePreferenceValue.php create mode 100644 src/AgentCommerce/Struct/V1/Value/DeliveryInstructionsValue.php create mode 100644 src/AgentCommerce/Struct/V1/Value/GiftMessageValue.php create mode 100644 src/AgentCommerce/Struct/V1/Value/GiftRecipientEmailValue.php create mode 100644 src/AgentCommerce/Struct/V1/Value/GiftRecipientNameValue.php create mode 100644 src/AgentCommerce/Struct/V1/Value/PrivacyConsentValue.php create mode 100644 src/AgentCommerce/Struct/V1/Value/TermsAcceptanceValue.php create mode 100644 src/AgentCommerce/Util/AgentDebugIDProcessor.php create mode 100644 src/AgentCommerce/Validation/HasScopes.php rename src/Resources/app/administration/src/module/swag-paypal-agent-commerce/static/product-export/{body.txt => body.csv.twig} (100%) rename src/Resources/app/administration/src/module/swag-paypal-agent-commerce/static/product-export/{header.txt => header.csv.twig} (100%) create mode 100644 tests/AgentCommerce/Exception/AgentExceptionTest.php create mode 100644 tests/AgentCommerce/Exception/AgentHttpExceptionTest.php create mode 100644 tests/AgentCommerce/Validation/HasScopesTest.php diff --git a/src/AgentCommerce/Exception/AgentHttpException.php b/src/AgentCommerce/Exception/AgentHttpException.php new file mode 100644 index 000000000..8f4d750cc --- /dev/null +++ b/src/AgentCommerce/Exception/AgentHttpException.php @@ -0,0 +1,32 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Exception; + +use Shopware\Core\Framework\HttpException; +use Shopware\Core\Framework\Log\Package; +use Swag\PayPal\AgentCommerce\Struct\V1\AgentErrorDetailCollection; + +#[Package('checkout')] +abstract class AgentHttpException extends HttpException +{ + public function __construct( + int $statusCode, + string $errorCode, + string $message, + array $parameters = [], + protected AgentErrorDetailCollection $details = new AgentErrorDetailCollection(), + ?\Throwable $previous = null + ) { + parent::__construct($statusCode, $errorCode, $message, $parameters, $previous); + } + + public function getDetails(): AgentErrorDetailCollection + { + return $this->details; + } +} diff --git a/src/AgentCommerce/Routing/AgentContextResolverListener.php b/src/AgentCommerce/Routing/AgentContextResolverListener.php new file mode 100644 index 000000000..bc051fd23 --- /dev/null +++ b/src/AgentCommerce/Routing/AgentContextResolverListener.php @@ -0,0 +1,44 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Routing; + +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Routing\KernelListenerPriorities; +use Shopware\Core\Framework\Routing\RequestContextResolverInterface; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpKernel\Event\ControllerEvent; +use Symfony\Component\HttpKernel\KernelEvents; + +/** + * @internal + */ +#[Package('checkout')] +class AgentContextResolverListener implements EventSubscriberInterface +{ + /** + * @internal + */ + public function __construct( + private readonly RequestContextResolverInterface $requestContextResolver + ) { + } + + public static function getSubscribedEvents(): array + { + return [ + KernelEvents::CONTROLLER => [ + ['resolveContext', KernelListenerPriorities::KERNEL_CONTROLLER_EVENT_CONTEXT_RESOLVE], + ], + ]; + } + + public function resolveContext(ControllerEvent $event): void + { + $this->requestContextResolver->resolve($event->getRequest()); + } +} diff --git a/src/AgentCommerce/SalesChannel/Response/AgentResponseExceptionSubscriber.php b/src/AgentCommerce/SalesChannel/Response/AgentResponseExceptionSubscriber.php new file mode 100644 index 000000000..e1cd46d07 --- /dev/null +++ b/src/AgentCommerce/SalesChannel/Response/AgentResponseExceptionSubscriber.php @@ -0,0 +1,109 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\SalesChannel\Response; + +use Psr\Log\LoggerInterface; +use Shopware\Core\Framework\Context; +use Shopware\Core\Framework\HttpException; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Routing\RouteScopeCheckTrait; +use Shopware\Core\Framework\Routing\RouteScopeRegistry; +use Shopware\Core\PlatformRequest; +use Swag\PayPal\AgentCommerce\Exception\AgentHttpException; +use Swag\PayPal\AgentCommerce\Routing\AgentRouteScope; +use Swag\PayPal\AgentCommerce\Routing\AgentSource; +use Swag\PayPal\AgentCommerce\Struct\V1\AgentError; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Event\ExceptionEvent; +use Symfony\Component\HttpKernel\KernelEvents; + +/** + * @internal + */ +#[Package('checkout')] +class AgentResponseExceptionSubscriber implements EventSubscriberInterface +{ + use RouteScopeCheckTrait; + + public function __construct( + private readonly LoggerInterface $logger, + private readonly RouteScopeRegistry $routeScopeRegistry, + ) { + } + + public static function getSubscribedEvents(): array + { + return [ + KernelEvents::EXCEPTION => [ + ['onKernelException', 0], + ], + ]; + } + + public function onKernelException(ExceptionEvent $event): void + { + if (!$this->isRequestScoped($event->getRequest(), AgentRouteScope::class)) { + return; + } + + $exception = $event->getThrowable(); + + $this->logger->error($exception->getMessage(), ['exception' => $exception]); + + $source = self::extractSource($event); + $response = new JsonResponse(self::getResponseFromException($exception, $source)); + + $event->setResponse($response); + $event->stopPropagation(); + } + + protected function getScopeRegistry(): RouteScopeRegistry + { + return $this->routeScopeRegistry; + } + + private function getResponseFromException(\Throwable $exception, ?AgentSource $source = null): AgentError + { + $error = new AgentError(); + $error->setName('UNKNOWN_ERROR'); + $error->setCode(Response::HTTP_INTERNAL_SERVER_ERROR); + $error->setMessage($exception->getMessage()); + + if ($source) { + $error->setDebugId($source->debugId); + } + + if ($exception instanceof HttpException) { + $error->setName($exception->getErrorCode()); + $error->setCode($exception->getStatusCode()); + + if ($exception instanceof AgentHttpException) { + $error->setDetails($exception->getDetails()); + } + } + + return $error; + } + + private static function extractSource(ExceptionEvent $event): ?AgentSource + { + $context = $event->getRequest()->attributes->get(PlatformRequest::ATTRIBUTE_CONTEXT_OBJECT); + + if (!$context instanceof Context) { + return null; + } + + if (!$context->getSource() instanceof AgentSource) { + return null; + } + + return $context->getSource(); + } +} diff --git a/src/AgentCommerce/Struct/V1/Address.php b/src/AgentCommerce/Struct/V1/Address.php new file mode 100644 index 000000000..884adef30 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Address.php @@ -0,0 +1,154 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Struct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema( + schema: 'paypal_agentic_commerce_v1_address', + required: ['countryCode'] +)] +class Address extends Struct +{ + /** + * The first line of the address, such as number and street, for example, 173 Drury Lane. + * Needed for data entry, and Compliance and Risk checks. This field needs to pass the full address. + */ + #[OA\Property( + type: 'string', + maxLength: 300, + minLength: 0, + )] + protected ?string $addressLine1 = null; + + #[OA\Property( + type: 'string', + maxLength: 300, + minLength: 0, + )] + protected ?string $addressLine2 = null; + + /** + * The highest-level sub-division in a country, which is usually a province, state, or ISO-3166-2 subdivision. + * This data is formatted for postal delivery, for example, CA and not California. Value, by country, is UK. + * A county. US. A state. Canada. A province. Japan. A prefecture. Switzerland. A kanton. + */ + #[OA\Property( + type: 'string', + maxLength: 300, + minLength: 0, + )] + protected ?string $adminArea1 = null; + + /** + * A city, town, or village. Smaller than admin_area_level_1. + */ + #[OA\Property( + type: 'string', + maxLength: 120, + minLength: 0, + )] + protected ?string $adminArea2 = null; + + /** + * The postal code, which is the ZIP code or equivalent. + * Typically required for countries with a postal code or an equivalent. See postal code. + */ + #[OA\Property( + type: 'string', + maxLength: 60, + minLength: 0, + )] + protected ?string $postalCode = null; + + /** + * The 2-character ISO 3166-1 alpha-2 country code + */ + #[OA\Property( + type: 'string', + maxLength: 2, + minLength: 2, + pattern: '^[A-Z]{2}$' + )] + protected string $countryCode; + + public function getAddressLine1(): ?string + { + return $this->addressLine1; + } + + public function setAddressLine1(?string $addressLine1): void + { + $this->addressLine1 = $addressLine1; + } + + public function getAddressLine2(): ?string + { + return $this->addressLine2; + } + + public function setAddressLine2(?string $addressLine2): void + { + $this->addressLine2 = $addressLine2; + } + + public function getAdminArea1(): ?string + { + return $this->adminArea1; + } + + public function setAdminArea1(?string $adminArea1): void + { + $this->adminArea1 = $adminArea1; + } + + public function getAdminArea2(): ?string + { + return $this->adminArea2; + } + + public function setAdminArea2(?string $adminArea2): void + { + $this->adminArea2 = $adminArea2; + } + + public function getPostalCode(): ?string + { + return $this->postalCode; + } + + public function setPostalCode(?string $postalCode): void + { + $this->postalCode = $postalCode; + } + + public function getCountryCode(): string + { + return $this->countryCode; + } + + public function setCountryCode(string $countryCode): void + { + if (!preg_match('/^[A-Z]{2}$/', $countryCode)) { + throw new \InvalidArgumentException(\sprintf('Country code "%s" is not valid.', $countryCode)); + } + + $this->countryCode = $countryCode; + } + + public function jsonSerialize(): array + { + return \array_filter(parent::jsonSerialize()); + } +} diff --git a/src/AgentCommerce/Struct/V1/AgentError.php b/src/AgentCommerce/Struct/V1/AgentError.php new file mode 100644 index 000000000..e7b4ce5e3 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/AgentError.php @@ -0,0 +1,78 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1; + +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Struct; + +/** + * @experimental + */ +#[Package('checkout')] +class AgentError extends Struct +{ + protected string $name; + + protected int $code; + + protected string $message; + + protected ?string $debugId = null; + + protected ?AgentErrorDetailCollection $details = null; + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public function getCode(): int + { + return $this->code; + } + + public function setCode(int $code): void + { + $this->code = $code; + } + + public function getMessage(): string + { + return $this->message; + } + + public function setMessage(string $message): void + { + $this->message = $message; + } + + public function getDebugId(): ?string + { + return $this->debugId; + } + + public function setDebugId(?string $debugId): void + { + $this->debugId = $debugId; + } + + public function getDetails(): ?AgentErrorDetailCollection + { + return $this->details; + } + + public function setDetails(?AgentErrorDetailCollection $details): void + { + $this->details = $details; + } +} diff --git a/src/AgentCommerce/Struct/V1/AgentErrorDetail.php b/src/AgentCommerce/Struct/V1/AgentErrorDetail.php new file mode 100644 index 000000000..fbd2c3495 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/AgentErrorDetail.php @@ -0,0 +1,54 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1; + +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Struct; + +/** + * @experimental + */ +#[Package('checkout')] +class AgentErrorDetail extends Struct +{ + protected string $field; + + protected string $issue; + + protected string $description; + + public function getField(): string + { + return $this->field; + } + + public function setField(string $field): void + { + $this->field = $field; + } + + public function getIssue(): string + { + return $this->issue; + } + + public function setIssue(string $issue): void + { + $this->issue = $issue; + } + + public function getDescription(): string + { + return $this->description; + } + + public function setDescription(string $description): void + { + $this->description = $description; + } +} diff --git a/src/AgentCommerce/Struct/V1/AgentErrorDetailCollection.php b/src/AgentCommerce/Struct/V1/AgentErrorDetailCollection.php new file mode 100644 index 000000000..a491c4922 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/AgentErrorDetailCollection.php @@ -0,0 +1,25 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1; + +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Collection; + +/** + * @experimental + * + * @extends Collection + */ +#[Package('checkout')] +class AgentErrorDetailCollection extends Collection +{ + protected function getExpectedClass(): string + { + return AgentErrorDetail::class; + } +} diff --git a/src/AgentCommerce/Struct/V1/AppliedCoupon.php b/src/AgentCommerce/Struct/V1/AppliedCoupon.php new file mode 100644 index 000000000..3ea958bd9 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/AppliedCoupon.php @@ -0,0 +1,64 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Struct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema(schema: 'paypal_agentic_commerce_v1_applied_coupon')] +class AppliedCoupon extends Struct +{ + #[OA\Property(type: 'string')] + protected ?string $code = null; + + #[OA\Property(type: 'string')] + protected ?string $description = null; + + #[OA\Property(ref: Money::class)] + protected ?Money $discountAmount = null; + + public function getCode(): ?string + { + return $this->code; + } + + public function setCode(?string $code): void + { + $this->code = $code; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(?string $description): void + { + $this->description = $description; + } + + public function getDiscountAmount(): ?Money + { + return $this->discountAmount; + } + + public function setDiscountAmount(?Money $discountAmount): void + { + $this->discountAmount = $discountAmount; + } + + public function jsonSerialize(): array + { + return \array_filter(parent::jsonSerialize()); + } +} diff --git a/src/AgentCommerce/Struct/V1/AppliedCouponCollection.php b/src/AgentCommerce/Struct/V1/AppliedCouponCollection.php new file mode 100644 index 000000000..42a066690 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/AppliedCouponCollection.php @@ -0,0 +1,25 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1; + +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Collection; + +/** + * @experimental + * + * @extends Collection + */ +#[Package('checkout')] +class AppliedCouponCollection extends Collection +{ + protected function getExpectedClass(): string + { + return AppliedCoupon::class; + } +} diff --git a/src/AgentCommerce/Struct/V1/BillingAddress.php b/src/AgentCommerce/Struct/V1/BillingAddress.php new file mode 100644 index 000000000..fc3d4cb76 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/BillingAddress.php @@ -0,0 +1,50 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1; + +use Shopware\Core\Framework\Log\Package; + +/** + * Billing address for merchant business purposes, obtained from customer's PayPal profile. Similar to shipping addresses, billing addresses can be retrieved from customer's default address information stored in their PayPal account. + * + * When Billing Address is Available: + * + * Customer has a default billing address in their PayPal profile + * PayPal Credit and Buy Now Pay Later transactions + * Guest checkout with credit/debit cards + * User explicitly consents to address sharing + * Required for tax compliance and regulatory reporting + * + * Primary Use Cases: + * + * Tax calculation: Sales tax/VAT rates determined by billing jurisdiction + * Export compliance: Product restrictions based on customer's billing country + * Financial reporting: Accounting systems requiring customer billing location + * Address verification: Comparing billing vs shipping addresses for fraud prevention + * + * Secondary Use Cases: + * + * Business intelligence: Customer demographics and market analysis + * B2B invoicing: Legal invoices requiring customer billing details + * Compliance reporting: Regulatory requirements based on customer location + * + * Note: Payment verification (AVS) and chargeback protection are handled by PayPal internally. + * + * Implementation Notes: + * + * Billing address is typically available from customer profile data + * Can be populated during cart creation if customer provides it + * Falls back to shipping address when billing address is not specified + * Merchants should handle graceful fallback scenarios + * + * @experimental + */ +#[Package('checkout')] +class BillingAddress extends Address +{ +} diff --git a/src/AgentCommerce/Struct/V1/Builder/MetaDataBuilder.php b/src/AgentCommerce/Struct/V1/Builder/MetaDataBuilder.php new file mode 100644 index 000000000..fa40da7d7 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Builder/MetaDataBuilder.php @@ -0,0 +1,72 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1\Builder; + +use Shopware\Core\Framework\Log\Package; +use Swag\PayPal\AgentCommerce\Struct\V1\Referral\MetaData; +use Swag\PayPal\AgentCommerce\Struct\V1\ResolutionOption; + +#[Package('checkout')] +final class MetaDataBuilder +{ + public function __construct( + private readonly ResolutionOption $resolutionOption, + private readonly ResolutionBuilder $resolutionBuilder, + ) { + if (!$this->resolutionOption->isset('metadata')) { + $this->resolutionOption->setMetadata(new MetaData()); + } + } + + public function withCostImpact(string $costImpact): self + { + $this->resolutionOption->getMetadata()->setCostImpact($costImpact); + + return $this; + } + + public function withPriority(string $priority): self + { + $this->resolutionOption->getMetadata()->setPriority($priority); + + return $this; + } + + public function withWaist(string $waist): self + { + $this->resolutionOption->getMetadata()->setWaist($waist); + + return $this; + } + + public function withAutoApplicable(bool $autoApplicable): self + { + $this->resolutionOption->getMetadata()->setAutoApplicable($autoApplicable); + + return $this; + } + + public function withEstimatedTime(string $estimatedTime): self + { + $this->resolutionOption->getMetadata()->setEstimatedTime($estimatedTime); + + return $this; + } + + public function withRedirectRequired(bool $redirectRequired): self + { + $this->resolutionOption->getMetadata()->setRedirectRequired($redirectRequired); + + return $this; + } + + public function end(): ResolutionBuilder + { + return $this->resolutionBuilder; + } +} diff --git a/src/AgentCommerce/Struct/V1/Builder/ResolutionBuilder.php b/src/AgentCommerce/Struct/V1/Builder/ResolutionBuilder.php new file mode 100644 index 000000000..253012c7b --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Builder/ResolutionBuilder.php @@ -0,0 +1,62 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1\Builder; + +use Shopware\Core\Framework\Log\Package; +use Swag\PayPal\AgentCommerce\Struct\V1\ResolutionOption; +use Swag\PayPal\AgentCommerce\Struct\V1\ResolutionOptionCollection; +use Swag\PayPal\AgentCommerce\Struct\V1\ValidationIssue; + +#[Package('checkout')] +final class ResolutionBuilder +{ + private ResolutionOption $resolution; + + public function __construct( + private readonly ValidationIssue $issue, + private readonly ValidationIssueBuilder $validationIssueBuilder, + ) { + if (!$this->issue->isset('resolutionOptions')) { + $this->issue->setResolutionOptions(new ResolutionOptionCollection()); + } + + $this->resolution = new ResolutionOption(); + $this->issue->getResolutionOptions()->add($this->resolution); + } + + public function withAction(string $action): self + { + $this->resolution->setAction($action); + + return $this; + } + + public function withLabel(string $label): self + { + $this->resolution->setLabel($label); + + return $this; + } + + public function withUrl(string $url): self + { + $this->resolution->setUrl($url); + + return $this; + } + + public function withMetadata(): MetaDataBuilder + { + return new MetaDataBuilder($this->resolution, $this); + } + + public function end(): ValidationIssueBuilder + { + return $this->validationIssueBuilder; + } +} diff --git a/src/AgentCommerce/Struct/V1/Builder/ValidationIssueBuilder.php b/src/AgentCommerce/Struct/V1/Builder/ValidationIssueBuilder.php new file mode 100644 index 000000000..49f9b03ac --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Builder/ValidationIssueBuilder.php @@ -0,0 +1,90 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1\Builder; + +use Shopware\Core\Framework\Log\Package; +use Swag\PayPal\AgentCommerce\Struct\V1\Context\AbstractContext; +use Swag\PayPal\AgentCommerce\Struct\V1\ResolutionOptionCollection; +use Swag\PayPal\AgentCommerce\Struct\V1\ValidationIssue; + +#[Package('checkout')] +final class ValidationIssueBuilder +{ + private readonly ValidationIssue $issue; + + public function __construct() + { + $this->issue = new ValidationIssue(); + } + + public function build(): ValidationIssue + { + return $this->issue; + } + + public function withCode(string $code): self + { + $this->issue->setCode($code); + + return $this; + } + + public function withType(string $type): self + { + $this->issue->setType($type); + + return $this; + } + + public function withMessage(string $message): self + { + $this->issue->setMessage($message); + + return $this; + } + + public function withUserMessage(string $userMessage): self + { + $this->issue->setUserMessage($userMessage); + + return $this; + } + + public function withItemId(string $itemId): self + { + $this->issue->setItemId($itemId); + + return $this; + } + + public function withField(string $field): self + { + $this->issue->setField($field); + + return $this; + } + + public function withContext(AbstractContext $context): self + { + $this->issue->setContext($context); + + return $this; + } + + public function withResolutionOptions(ResolutionOptionCollection $resolutionOptions): self + { + $this->issue->setResolutionOptions($resolutionOptions); + + return $this; + } + + public function addResolutionOption(): ResolutionBuilder + { + return new ResolutionBuilder($this->issue, $this); + } +} diff --git a/src/AgentCommerce/Struct/V1/CartItem.php b/src/AgentCommerce/Struct/V1/CartItem.php new file mode 100644 index 000000000..e6db8df07 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/CartItem.php @@ -0,0 +1,194 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Struct; +use Swag\PayPal\AgentCommerce\Struct\V1\Referral\CustomOptionCollection; +use Swag\PayPal\AgentCommerce\Struct\V1\Referral\SelectedAttributeCollection; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema( + schema: 'paypal_agentic_commerce_v1_cart_item', + required: ['itemId', 'quantity', 'price'] +)] +class CartItem extends Struct +{ + /** + * Unique product identifier (optional in v1 for backwards compatibility) + */ + #[OA\Property(type: 'string')] + protected ?string $itemId = null; + + /** + * Product variant identifier (color, size, etc.) - unique id of the product + */ + #[OA\Property(type: 'string')] + protected ?string $variantId = null; + + /** + * Item grouping identifier - passed when item is part of a group in honey catalog + */ + #[OA\Property(type: 'string')] + protected ?string $parentId = null; + + /** + * Number of items + */ + #[OA\Property(type: 'integer', minimum: 1)] + protected int $quantity; + + /** + * Product display name + */ + #[OA\Property(type: 'string')] + protected ?string $name = null; + + /** + * Product description + */ + #[OA\Property(type: 'string')] + protected ?string $description = null; + + /** + * URL for product details page + */ + #[OA\Property(type: 'string')] + protected ?string $itemUrl = null; + + #[OA\Property(ref: Money::class)] + protected ?Money $price = null; + + #[OA\Property(ref: SelectedAttributeCollection::class)] + protected SelectedAttributeCollection $selectedAttributes; + + #[OA\Property(ref: GiftOptions::class)] + protected ?GiftOptions $giftOptions = null; + + #[OA\Property(ref: CustomOptionCollection::class)] + protected CustomOptionCollection $customOptions; + + public function getItemId(): ?string + { + return $this->itemId; + } + + public function setItemId(?string $itemId): void + { + $this->itemId = $itemId; + } + + public function getVariantId(): ?string + { + return $this->variantId; + } + + public function setVariantId(?string $variantId): void + { + $this->variantId = $variantId; + } + + public function getParentId(): ?string + { + return $this->parentId; + } + + public function setParentId(?string $parentId): void + { + $this->parentId = $parentId; + } + + public function getQuantity(): int + { + return $this->quantity; + } + + public function setQuantity(int $quantity): void + { + $this->quantity = $quantity; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(?string $name): void + { + $this->name = $name; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(?string $description): void + { + $this->description = $description; + } + + public function getItemUrl(): ?string + { + return $this->itemUrl; + } + + public function setItemUrl(?string $itemUrl): void + { + $this->itemUrl = $itemUrl; + } + + public function getPrice(): ?Money + { + return $this->price; + } + + public function setPrice(?Money $price): void + { + $this->price = $price; + } + + public function getSelectedAttributes(): SelectedAttributeCollection + { + return $this->selectedAttributes; + } + + public function setSelectedAttributes(SelectedAttributeCollection $selectedAttributes): void + { + $this->selectedAttributes = $selectedAttributes; + } + + public function getGiftOptions(): ?GiftOptions + { + return $this->giftOptions; + } + + public function setGiftOptions(?GiftOptions $giftOptions): void + { + $this->giftOptions = $giftOptions; + } + + public function getCustomOptions(): CustomOptionCollection + { + return $this->customOptions; + } + + public function setCustomOptions(CustomOptionCollection $customOptions): void + { + $this->customOptions = $customOptions; + } + + public function jsonSerialize(): array + { + return \array_filter(parent::jsonSerialize()); + } +} diff --git a/src/AgentCommerce/Struct/V1/CartItemCollection.php b/src/AgentCommerce/Struct/V1/CartItemCollection.php new file mode 100644 index 000000000..48ba40b39 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/CartItemCollection.php @@ -0,0 +1,25 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1; + +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Collection; + +/** + * @experimental + * + * @extends Collection + */ +#[Package('checkout')] +class CartItemCollection extends Collection +{ + protected function getExpectedClass(): string + { + return CartItem::class; + } +} diff --git a/src/AgentCommerce/Struct/V1/CartTotals.php b/src/AgentCommerce/Struct/V1/CartTotals.php new file mode 100644 index 000000000..3b5b7038e --- /dev/null +++ b/src/AgentCommerce/Struct/V1/CartTotals.php @@ -0,0 +1,168 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Struct; + +/** + * @experimental + * + * Comprehensive cart pricing breakdown calculated by merchant and returned in all cart responses. All fields are merchant-owned and calculated based on business logic, inventory, shipping rules, and tax regulations. + * + * Merchant Responsibility: + * + * Calculate all totals based on items, shipping address, and business rules + * Ensure accuracy for tax compliance and customer transparency + * Handle currency consistency across all money fields + * Apply discounts, shipping rates, and custom charges appropriately + * + * Field Calculation Guidelines: + * + * subtotal: Sum of all item prices × quantities (before discounts) + * discount: Total amount saved from coupons, promotions, and discounts + * shipping: Calculated shipping cost based on address and selected method + * tax: Sales tax, VAT, or other applicable taxes based on billing/shipping jurisdiction + * handling: Processing fees, packaging costs, or handling charges + * insurance: Optional shipping insurance costs + * shipping_discount: Discounts applied specifically to shipping costs + * custom_charges: Additional fees like gift wrapping, expedited processing, etc. + * total: Final amount customer pays (subtotal - discount + shipping + tax + handling + insurance - shipping_discount + custom_charges) + * + * PayPal Orders API Integration: When creating PayPal orders, custom_charges are typically rolled into the handling field or added as separate line items. The total amount must match the PayPal order total for successful payment capture. Fields map to PayPal Orders API breakdown structure where supported. + */ +#[Package('checkout')] +#[OA\Schema( + schema: 'paypal_agentic_commerce_v1_cart_totals', + required: ['total'] +)] +class CartTotals extends Struct +{ + #[OA\Property(ref: Money::class)] + protected ?Money $subtotal = null; + + #[OA\Property(ref: Money::class)] + protected ?Money $discount = null; + + #[OA\Property(ref: Money::class)] + protected ?Money $shipping = null; + + #[OA\Property(ref: Money::class)] + protected ?Money $tax = null; + + #[OA\Property(ref: Money::class)] + protected ?Money $handling = null; + + #[OA\Property(ref: Money::class)] + protected ?Money $insurance = null; + + #[OA\Property(ref: Money::class)] + protected ?Money $shippingDiscount = null; + + #[OA\Property(ref: Money::class)] + protected ?Money $customCharges = null; + + #[OA\Property(ref: Money::class)] + protected Money $total; + + public function getSubtotal(): ?Money + { + return $this->subtotal; + } + + public function setSubtotal(?Money $subtotal): void + { + $this->subtotal = $subtotal; + } + + public function getDiscount(): ?Money + { + return $this->discount; + } + + public function setDiscount(?Money $discount): void + { + $this->discount = $discount; + } + + public function getShipping(): ?Money + { + return $this->shipping; + } + + public function setShipping(?Money $shipping): void + { + $this->shipping = $shipping; + } + + public function getTax(): ?Money + { + return $this->tax; + } + + public function setTax(?Money $tax): void + { + $this->tax = $tax; + } + + public function getHandling(): ?Money + { + return $this->handling; + } + + public function setHandling(?Money $handling): void + { + $this->handling = $handling; + } + + public function getInsurance(): ?Money + { + return $this->insurance; + } + + public function setInsurance(?Money $insurance): void + { + $this->insurance = $insurance; + } + + public function getShippingDiscount(): ?Money + { + return $this->shippingDiscount; + } + + public function setShippingDiscount(?Money $shippingDiscount): void + { + $this->shippingDiscount = $shippingDiscount; + } + + public function getCustomCharges(): ?Money + { + return $this->customCharges; + } + + public function setCustomCharges(?Money $customCharges): void + { + $this->customCharges = $customCharges; + } + + public function getTotal(): Money + { + return $this->total; + } + + public function setTotal(Money $total): void + { + $this->total = $total; + } + + public function jsonSerialize(): array + { + return \array_filter(parent::jsonSerialize()); + } +} diff --git a/src/AgentCommerce/Struct/V1/CheckoutField.php b/src/AgentCommerce/Struct/V1/CheckoutField.php new file mode 100644 index 000000000..bd4b1cf15 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/CheckoutField.php @@ -0,0 +1,220 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Struct; +use Swag\PayPal\AgentCommerce\Struct\V1\Value\AgeVerificationValue; +use Swag\PayPal\AgentCommerce\Struct\V1\Value\AllergyInformationValue; +use Swag\PayPal\AgentCommerce\Struct\V1\Value\CustomEngravingTextValue; +use Swag\PayPal\AgentCommerce\Struct\V1\Value\CustomSizingInfoValue; +use Swag\PayPal\AgentCommerce\Struct\V1\Value\DeliveryDatePreferenceValue; +use Swag\PayPal\AgentCommerce\Struct\V1\Value\DeliveryInstructionsValue; +use Swag\PayPal\AgentCommerce\Struct\V1\Value\GiftMessageValue; +use Swag\PayPal\AgentCommerce\Struct\V1\Value\GiftRecipientEmailValue; +use Swag\PayPal\AgentCommerce\Struct\V1\Value\GiftRecipientNameValue; +use Swag\PayPal\AgentCommerce\Struct\V1\Value\PrivacyConsentValue; +use Swag\PayPal\AgentCommerce\Struct\V1\Value\TermsAcceptanceValue; + +/** + * @experimental + * + * PayPal-controlled checkout field for buyer data collection with structured values and validation. + * + * Field Lifecycle: + * + * PENDING: Field needs customer input + * COMPLETED: Valid value provided and accepted + * REJECTED: Invalid/unacceptable value provided + * ERROR: System error during processing + * + * Structured Values: Each field type has a specific value schema based on its requirements. Age verification uses boolean confirmation, text fields use strings, etc. + */ +#[Package('checkout')] +#[OA\Schema( + schema: 'paypal_agentic_commerce_v1_checkout_field', + required: ['type', 'status'] +)] +class CheckoutField extends Struct +{ + public const TYPE__AGE_VERIFICATION_18_PLUS = 'AGE_VERIFICATION_18_PLUS'; + public const TYPE__AGE_VERIFICATION_21_PLUS = 'AGE_VERIFICATION_21_PLUS'; + public const TYPE__GIFT_RECIPIENT_EMAIL = 'GIFT_RECIPIENT_EMAIL'; + public const TYPE__GIFT_RECIPIENT_NAME = 'GIFT_RECIPIENT_NAME'; + public const TYPE__GIFT_MESSAGE = 'GIFT_MESSAGE'; + public const TYPE__DELIVERY_INSTRUCTIONS = 'DELIVERY_INSTRUCTIONS'; + public const TYPE__DELIVERY_DATE_PREFERENCE = 'DELIVERY_DATE_PREFERENCE'; + public const TYPE__ALLERGY_INFORMATION = 'ALLERGY_INFORMATION'; + public const TYPE__CUSTOM_ENGRAVING_TEXT = 'CUSTOM_ENGRAVING_TEXT'; + public const TYPE__CUSTOM_SIZING_INFO = 'CUSTOM_SIZING_INFO'; + public const TYPE__TERMS_ACCEPTANCE = 'TERMS_ACCEPTANCE'; + public const TYPE__PRIVACY_CONSENT = 'PRIVACY_CONSENT'; + + public const STATUS__PENDING = 'PENDING'; + public const STATUS__COMPLETED = 'COMPLETED'; + public const STATUS__REJECTED = 'REJECTED'; + public const STATUS__ERROR = 'ERROR'; + + private const TYPES = [ + self::TYPE__AGE_VERIFICATION_18_PLUS, + self::TYPE__AGE_VERIFICATION_21_PLUS, + self::TYPE__GIFT_RECIPIENT_EMAIL, + self::TYPE__GIFT_RECIPIENT_NAME, + self::TYPE__GIFT_MESSAGE, + self::TYPE__DELIVERY_INSTRUCTIONS, + self::TYPE__DELIVERY_DATE_PREFERENCE, + self::TYPE__ALLERGY_INFORMATION, + self::TYPE__CUSTOM_ENGRAVING_TEXT, + self::TYPE__CUSTOM_SIZING_INFO, + self::TYPE__TERMS_ACCEPTANCE, + self::TYPE__PRIVACY_CONSENT, + ]; + + private const STATUSES = [ + self::STATUS__PENDING, + self::STATUS__COMPLETED, + self::STATUS__REJECTED, + self::STATUS__ERROR, + ]; + + /** + * PayPal-approved checkout field type + */ + #[OA\Property( + type: 'string', + enum: self::TYPES, + )] + protected string $type; + + /** + * Field completion and validation status: + * + * PENDING: Field needs customer input + * + * Initial state when field is required + * AI agent should collect this information + * value field is null or empty + * + * COMPLETED: Valid value provided and accepted + * + * Customer provided acceptable input + * Value passes all validation rules + * Cart can proceed with this field resolved + * + * REJECTED: Invalid or unacceptable value provided + * + * Customer provided input that doesn't meet requirements + * validation_issue explains the specific problem + * AI agent should request corrected input + * + * ERROR: System error during processing + * + * Technical failure in field processing + * Should retry or escalate to support + * Not caused by customer input + */ + #[OA\Property( + type: 'string', + enum: self::STATUSES + )] + protected string $status; + + /** + * Structured value based on field type. Each checkout field type has a specific value schema. + * Use oneOf to validate against the appropriate structure for the field type. + */ + #[OA\Property(oneOf: [ + new OA\Schema(ref: AgeVerificationValue::class), + new OA\Schema(ref: GiftRecipientEmailValue::class), + new OA\Schema(ref: GiftRecipientNameValue::class), + new OA\Schema(ref: GiftMessageValue::class), + new OA\Schema(ref: DeliveryInstructionsValue::class), + new OA\Schema(ref: DeliveryDatePreferenceValue::class), + new OA\Schema(ref: AllergyInformationValue::class), + new OA\Schema(ref: CustomEngravingTextValue::class), + new OA\Schema(ref: CustomSizingInfoValue::class), + new OA\Schema(ref: TermsAcceptanceValue::class), + new OA\Schema(ref: PrivacyConsentValue::class), + ])] + protected Struct $value; + + /** + * Additional context and metadata for the checkout field. + * This is a flexible object that can contain any field-specific information needed for validation, display, or processing. + * The structure varies based on the field type. + */ + #[OA\Property(type: 'mixed')] + protected mixed $context = null; + + #[OA\Property(ref: ValidationIssue::class)] + protected ?ValidationIssue $validationIssue = null; + + public function getType(): string + { + return $this->type; + } + + public function setType(string $type): void + { + if (!\in_array($type, self::TYPES, true)) { + throw new \InvalidArgumentException(\sprintf('Type "%s" is not valid.', $type)); + } + + $this->type = $type; + } + + public function getStatus(): string + { + return $this->status; + } + + public function setStatus(string $status): void + { + if (!\in_array($status, self::STATUSES, true)) { + throw new \InvalidArgumentException(\sprintf('Status "%s" is not valid.', $status)); + } + + $this->status = $status; + } + + public function getValue(): Struct + { + return $this->value; + } + + public function setValue(Struct $value): void + { + $this->value = $value; + } + + public function getContext(): mixed + { + return $this->context; + } + + public function setContext(mixed $context): void + { + $this->context = $context; + } + + public function getValidationIssue(): ?ValidationIssue + { + return $this->validationIssue; + } + + public function setValidationIssue(?ValidationIssue $validationIssue): void + { + $this->validationIssue = $validationIssue; + } + + public function jsonSerialize(): array + { + return \array_filter(parent::jsonSerialize()); + } +} diff --git a/src/AgentCommerce/Struct/V1/CheckoutFieldCollection.php b/src/AgentCommerce/Struct/V1/CheckoutFieldCollection.php new file mode 100644 index 000000000..3f447b656 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/CheckoutFieldCollection.php @@ -0,0 +1,25 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1; + +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Collection; + +/** + * @experimental + * + * @extends Collection + */ +#[Package('checkout')] +class CheckoutFieldCollection extends Collection +{ + protected function getExpectedClass(): string + { + return CheckoutField::class; + } +} diff --git a/src/AgentCommerce/Struct/V1/Context/AbstractContext.php b/src/AgentCommerce/Struct/V1/Context/AbstractContext.php new file mode 100644 index 000000000..b07879e1a --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Context/AbstractContext.php @@ -0,0 +1,49 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1\Context; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Struct; + +/** + * @experimental + */ +#[Package('checkout')] +abstract class AbstractContext extends Struct +{ + /** + * Specific business rule issue type + */ + #[OA\Property(type: 'string')] + protected string $specificIssue; + + public function getSpecificIssue(): string + { + return $this->specificIssue; + } + + public function setSpecificIssue(string $specificIssue): void + { + if (!\in_array($specificIssue, static::getSpecificIssues(), true)) { + throw new \InvalidArgumentException(\sprintf('Specific issue "%s" is not valid.', $specificIssue)); + } + + $this->specificIssue = $specificIssue; + } + + public function jsonSerialize(): array + { + return \array_filter(parent::jsonSerialize()); + } + + /** + * @return string[] + */ + abstract protected static function getSpecificIssues(): array; +} diff --git a/src/AgentCommerce/Struct/V1/Context/BusinessRuleErrorContext.php b/src/AgentCommerce/Struct/V1/Context/BusinessRuleErrorContext.php new file mode 100644 index 000000000..0a5f9c8da --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Context/BusinessRuleErrorContext.php @@ -0,0 +1,403 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1\Context; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; +use Swag\PayPal\AgentCommerce\Struct\V1\Referral\BusinessHourCollection; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema(schema: 'paypal_agentic_commerce_v1_context_business_rule_error_context')] +class BusinessRuleErrorContext extends AbstractContext +{ + public const ISSUE__MINIMUM_ORDER_NOT_MET = 'MINIMUM_ORDER_NOT_MET'; + public const ISSUE__MINIMUM_QUANTITY_NOT_MET = 'MINIMUM_QUANTITY_NOT_MET'; + public const ISSUE__MAXIMUM_QUANTITY_EXCEEDED = 'MAXIMUM_QUANTITY_EXCEEDED'; + public const ISSUE__CART_LIMIT_EXCEEDED = 'CART_LIMIT_EXCEEDED'; + public const ISSUE__CUSTOMER_ACCOUNT_SUSPENDED = 'CUSTOMER_ACCOUNT_SUSPENDED'; + public const ISSUE__PURCHASE_LIMIT_EXCEEDED = 'PURCHASE_LIMIT_EXCEEDED'; + public const ISSUE__BULK_ORDER_APPROVAL_REQUIRED = 'BULK_ORDER_APPROVAL_REQUIRED'; + public const ISSUE__STORE_TEMPORARILY_CLOSED = 'STORE_TEMPORARILY_CLOSED'; + public const ISSUE__AGE_RESTRICTED_PRODUCT = 'AGE_RESTRICTED_PRODUCT'; + public const ISSUE__LOYALTY_PROGRAM_VALIDATION_FAILED = 'LOYALTY_PROGRAM_VALIDATION_FAILED'; + public const ISSUE__BUSINESS_HOURS_RESTRICTION = 'BUSINESS_HOURS_RESTRICTION'; + public const ISSUE__PRODUCT_ARCHIVED = 'PRODUCT_ARCHIVED'; + + /** + * Current order amount + */ + #[OA\Property(type: 'string')] + protected ?string $currentAmount = null; + + /** + * Required minimum amount + */ + #[OA\Property(type: 'string')] + protected ?string $requiredAmount = null; + + /** + * Maximum allowed amount + */ + #[OA\Property(type: 'string')] + protected ?string $maximumAmount = null; + + /** + * Amount needed to meet minimum + */ + #[OA\Property(type: 'string')] + protected ?string $remainingAmount = null; + + /** + * Customer account status + */ + #[OA\Property(type: 'string')] + protected ?string $accountStatus = null; + + /** + * Reason for account suspension + */ + #[OA\Property(type: 'string')] + protected ?string $suspensionReason = null; + + /** + * Date of account suspension + */ + #[OA\Property(type: 'string')] + protected ?string $suspensionDate = null; + + /** + * Monthly purchase limit + */ + #[OA\Property(type: 'string')] + protected ?string $monthlyLimit = null; + + /** + * Current month purchase total + */ + #[OA\Property(type: 'string')] + protected ?string $currentMonthTotal = null; + + /** + * When limits reset + */ + #[OA\Property(type: 'string')] + protected ?string $resetDate = null; + + /** + * Total quantity in bulk order + */ + #[OA\Property(type: 'integer')] + protected ?int $totalQuantity = null; + + /** + * Quantity requiring approval + */ + #[OA\Property(type: 'integer')] + protected ?int $approvalThreshold = null; + + /** + * When maintenance ends + */ + #[OA\Property(type: 'string')] + protected ?string $maintenanceEndTime = null; + + /** + * Current service status + */ + #[OA\Property(type: 'string')] + protected ?string $serviceStatus = null; + + /** + * Seconds before retry recommended + */ + #[OA\Property(type: 'integer')] + protected ?int $retryAfter = null; + + /** + * Support contact information + */ + #[OA\Property(type: 'string')] + protected ?string $contactInfo = null; + + /** + * Items with restrictions + * + * @var string[] + */ + #[OA\Property( + type: 'array', + items: new OA\Items(type: 'string'), + )] + protected ?array $restrictedItems = null; + + /** + * Required minimum age + */ + #[OA\Property(type: 'integer')] + protected ?int $ageRequirement = null; + + /** + * Store business hours + */ + #[OA\Property(ref: BusinessHourCollection::class)] + protected BusinessHourCollection $businessHours; + + /** + * Amount needed to meet minimum requirements + */ + #[OA\Property(type: 'string')] + protected ?string $shortageAmount = null; + + /** + * Amount by which limit is exceeded + */ + #[OA\Property(type: 'string')] + protected ?string $exceedsBy = null; + + public function getCurrentAmount(): ?string + { + return $this->currentAmount; + } + + public function setCurrentAmount(?string $currentAmount): void + { + $this->currentAmount = $currentAmount; + } + + public function getRequiredAmount(): ?string + { + return $this->requiredAmount; + } + + public function setRequiredAmount(?string $requiredAmount): void + { + $this->requiredAmount = $requiredAmount; + } + + public function getMaximumAmount(): ?string + { + return $this->maximumAmount; + } + + public function setMaximumAmount(?string $maximum_Amount): void + { + $this->maximumAmount = $maximum_Amount; + } + + public function getRemainingAmount(): ?string + { + return $this->remainingAmount; + } + + public function setRemainingAmount(?string $remainingAmount): void + { + $this->remainingAmount = $remainingAmount; + } + + public function getAccountStatus(): ?string + { + return $this->accountStatus; + } + + public function setAccountStatus(?string $accountStatus): void + { + $this->accountStatus = $accountStatus; + } + + public function getSuspensionReason(): ?string + { + return $this->suspensionReason; + } + + public function setSuspensionReason(?string $suspensionReason): void + { + $this->suspensionReason = $suspensionReason; + } + + public function getSuspensionDate(): ?string + { + return $this->suspensionDate; + } + + public function setSuspensionDate(?string $suspensionDate): void + { + $this->suspensionDate = $suspensionDate; + } + + public function getMonthlyLimit(): ?string + { + return $this->monthlyLimit; + } + + public function setMonthlyLimit(?string $monthlyLimit): void + { + $this->monthlyLimit = $monthlyLimit; + } + + public function getCurrentMonthTotal(): ?string + { + return $this->currentMonthTotal; + } + + public function setCurrentMonthTotal(?string $currentMonthTotal): void + { + $this->currentMonthTotal = $currentMonthTotal; + } + + public function getResetDate(): ?string + { + return $this->resetDate; + } + + public function setResetDate(?string $resetDate): void + { + $this->resetDate = $resetDate; + } + + public function getTotalQuantity(): ?int + { + return $this->totalQuantity; + } + + public function setTotalQuantity(?int $totalQuantity): void + { + $this->totalQuantity = $totalQuantity; + } + + public function getApprovalThreshold(): ?int + { + return $this->approvalThreshold; + } + + public function setApprovalThreshold(?int $approvalThreshold): void + { + $this->approvalThreshold = $approvalThreshold; + } + + public function getMaintenanceEndTime(): ?string + { + return $this->maintenanceEndTime; + } + + public function setMaintenanceEndTime(?string $maintenanceEndTime): void + { + $this->maintenanceEndTime = $maintenanceEndTime; + } + + public function getServiceStatus(): ?string + { + return $this->serviceStatus; + } + + public function setServiceStatus(?string $serviceStatus): void + { + $this->serviceStatus = $serviceStatus; + } + + public function getRetryAfter(): ?int + { + return $this->retryAfter; + } + + public function setRetryAfter(?int $retryAfter): void + { + $this->retryAfter = $retryAfter; + } + + public function getContactInfo(): ?string + { + return $this->contactInfo; + } + + public function setContactInfo(?string $contactInfo): void + { + $this->contactInfo = $contactInfo; + } + + /** + * @return ?string[] + */ + public function getRestrictedItems(): ?array + { + return $this->restrictedItems; + } + + /** + * @param ?string[] $restrictedItems + */ + public function setRestrictedItems(?array $restrictedItems): void + { + $this->restrictedItems = $restrictedItems; + } + + public function addRestrictedItem(string $restrictedItem): void + { + $this->restrictedItems[] = $restrictedItem; + } + + public function getAgeRequirement(): ?int + { + return $this->ageRequirement; + } + + public function setAgeRequirement(?int $ageRequirement): void + { + $this->ageRequirement = $ageRequirement; + } + + public function getBusinessHours(): BusinessHourCollection + { + return $this->businessHours; + } + + public function setBusinessHours(BusinessHourCollection $businessHours): void + { + $this->businessHours = $businessHours; + } + + public function getShortageAmount(): ?string + { + return $this->shortageAmount; + } + + public function setShortageAmount(?string $shortageAmount): void + { + $this->shortageAmount = $shortageAmount; + } + + public function getExceedsBy(): ?string + { + return $this->exceedsBy; + } + + public function setExceedsBy(?string $exceedsBy): void + { + $this->exceedsBy = $exceedsBy; + } + + protected static function getSpecificIssues(): array + { + return [ + self::ISSUE__MINIMUM_ORDER_NOT_MET, + self::ISSUE__MINIMUM_QUANTITY_NOT_MET, + self::ISSUE__MAXIMUM_QUANTITY_EXCEEDED, + self::ISSUE__CART_LIMIT_EXCEEDED, + self::ISSUE__CUSTOMER_ACCOUNT_SUSPENDED, + self::ISSUE__PURCHASE_LIMIT_EXCEEDED, + self::ISSUE__BULK_ORDER_APPROVAL_REQUIRED, + self::ISSUE__STORE_TEMPORARILY_CLOSED, + self::ISSUE__AGE_RESTRICTED_PRODUCT, + self::ISSUE__LOYALTY_PROGRAM_VALIDATION_FAILED, + self::ISSUE__BUSINESS_HOURS_RESTRICTION, + self::ISSUE__PRODUCT_ARCHIVED, + ]; + } +} diff --git a/src/AgentCommerce/Struct/V1/Context/DataErrorContext.php b/src/AgentCommerce/Struct/V1/Context/DataErrorContext.php new file mode 100644 index 000000000..6765be6e5 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Context/DataErrorContext.php @@ -0,0 +1,268 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1\Context; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema(schema: 'paypal_agentic_commerce_v1_context_data_error_context')] +class DataErrorContext extends AbstractContext +{ + public const ISSUE__MISSING_CHECKOUT_FIELDS = 'MISSING_CHECKOUT_FIELDS'; + public const ISSUE__MISSING_PAYMENT_METHOD = 'MISSING_PAYMENT_METHOD'; + public const ISSUE__MISSING_POLICY_ACCEPTANCE = 'MISSING_POLICY_ACCEPTANCE'; + public const ISSUE__REQUIRED_FIELD_MISSING = 'REQUIRED_FIELD_MISSING'; + public const ISSUE__INVALID_EMAIL_FORMAT = 'INVALID_EMAIL_FORMAT'; + public const ISSUE__INVALID_PHONE_FORMAT = 'INVALID_PHONE_FORMAT'; + public const ISSUE__FIELD_VALUE_TOO_LONG = 'FIELD_VALUE_TOO_LONG'; + public const ISSUE__FIELD_VALUE_TOO_SHORT = 'FIELD_VALUE_TOO_SHORT'; + public const ISSUE__INVALID_DATE_FORMAT = 'INVALID_DATE_FORMAT'; + public const ISSUE__FUTURE_DATE_NOT_ALLOWED = 'FUTURE_DATE_NOT_ALLOWED'; + public const ISSUE__INVALID_CUSTOMER_DATA = 'INVALID_CUSTOMER_DATA'; + public const ISSUE__ITEM_NOT_FOUND = 'ITEM_NOT_FOUND'; + public const ISSUE__INVALID_ITEM_DATA = 'INVALID_ITEM_DATA'; + public const ISSUE__ITEM_ATTRIBUTE_MISMATCH = 'ITEM_ATTRIBUTE_MISMATCH'; + + /** + * Name of the field with validation error + */ + #[OA\Property(type: 'string')] + protected ?string $fieldName = null; + + /** + * Value that failed validation + */ + #[OA\Property(type: 'string')] + protected ?string $providedValue = null; + + /** + * Expected format description + */ + #[OA\Property(type: 'string')] + protected ?string $expectedFormat = null; + + /** + * Maximum allowed length + */ + #[OA\Property(type: 'integer')] + protected ?int $maxLength = null; + + /** + * Minimum required length + */ + #[OA\Property(type: 'integer')] + protected ?int $minLength = null; + + /** + * Current value length + */ + #[OA\Property(type: 'integer')] + protected ?int $currentLength = null; + + /** + * Required regex pattern + */ + #[OA\Property(type: 'string')] + protected ?string $regexPattern = null; + + /** + * Suggested corrected value + */ + #[OA\Property(type: 'string')] + protected ?string $suggestedValue = null; + + /** + * List of allowed values for enum fields + * + * @var string[] + */ + #[OA\Property( + type: 'array', + items: new OA\Items(type: 'string'), + )] + protected ?array $allowedValues = null; + + /** + * List of required field names + * + * @var string[] + */ + #[OA\Property( + type: 'array', + items: new OA\Items(type: 'string'), + )] + protected ?array $requiredFields = null; + + /** + * Descriptions for required fields + * + * @var string[] + */ + #[OA\Property( + type: 'array', + items: new OA\Items(type: 'string'), + )] + protected ?array $fieldDescriptions = null; + + public function getFieldName(): ?string + { + return $this->fieldName; + } + + public function setFieldName(?string $fieldName): void + { + $this->fieldName = $fieldName; + } + + public function getProvidedValue(): ?string + { + return $this->providedValue; + } + + public function setProvidedValue(?string $providedValue): void + { + $this->providedValue = $providedValue; + } + + public function getExpectedFormat(): ?string + { + return $this->expectedFormat; + } + + public function setExpectedFormat(?string $expectedFormat): void + { + $this->expectedFormat = $expectedFormat; + } + + public function getMaxLength(): ?int + { + return $this->maxLength; + } + + public function setMaxLength(?int $maxLength): void + { + $this->maxLength = $maxLength; + } + + public function getMinLength(): ?int + { + return $this->minLength; + } + + public function setMinLength(?int $minLength): void + { + $this->minLength = $minLength; + } + + public function getCurrentLength(): ?int + { + return $this->currentLength; + } + + public function setCurrentLength(?int $currentLength): void + { + $this->currentLength = $currentLength; + } + + public function getRegexPattern(): ?string + { + return $this->regexPattern; + } + + public function setRegexPattern(?string $regexPattern): void + { + $this->regexPattern = $regexPattern; + } + + public function getSuggestedValue(): ?string + { + return $this->suggestedValue; + } + + public function setSuggestedValue(?string $suggestedValue): void + { + $this->suggestedValue = $suggestedValue; + } + + /** + * @return ?string[] + */ + public function getAllowedValues(): ?array + { + return $this->allowedValues; + } + + /** + * @param ?string[] $allowedValues + */ + public function setAllowedValues(?array $allowedValues): void + { + $this->allowedValues = $allowedValues; + } + + public function addAllowedValue(string $allowedValue): void + { + $this->allowedValues[] = $allowedValue; + } + + /** + * @return ?string[] + */ + public function getRequiredFields(): ?array + { + return $this->requiredFields; + } + + /** + * @param ?string[] $requiredFields + */ + public function setRequiredFields(?array $requiredFields): void + { + $this->requiredFields = $requiredFields; + } + + /** + * @return ?string[] + */ + public function getFieldDescriptions(): ?array + { + return $this->fieldDescriptions; + } + + /** + * @param ?string[] $fieldDescriptions + */ + public function setFieldDescriptions(?array $fieldDescriptions): void + { + $this->fieldDescriptions = $fieldDescriptions; + } + + protected static function getSpecificIssues(): array + { + return [ + self::ISSUE__MISSING_CHECKOUT_FIELDS, + self::ISSUE__MISSING_PAYMENT_METHOD, + self::ISSUE__MISSING_POLICY_ACCEPTANCE, + self::ISSUE__REQUIRED_FIELD_MISSING, + self::ISSUE__INVALID_EMAIL_FORMAT, + self::ISSUE__INVALID_PHONE_FORMAT, + self::ISSUE__FIELD_VALUE_TOO_LONG, + self::ISSUE__FIELD_VALUE_TOO_SHORT, + self::ISSUE__INVALID_DATE_FORMAT, + self::ISSUE__FUTURE_DATE_NOT_ALLOWED, + self::ISSUE__INVALID_CUSTOMER_DATA, + self::ISSUE__ITEM_NOT_FOUND, + self::ISSUE__INVALID_ITEM_DATA, + self::ISSUE__ITEM_ATTRIBUTE_MISMATCH, + ]; + } +} diff --git a/src/AgentCommerce/Struct/V1/Context/InventoryIssueContext.php b/src/AgentCommerce/Struct/V1/Context/InventoryIssueContext.php new file mode 100644 index 000000000..42940f6bd --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Context/InventoryIssueContext.php @@ -0,0 +1,298 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1\Context; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema(schema: 'paypal_agentic_commerce_v1_context_inventory_issue_context')] +class InventoryIssueContext extends AbstractContext +{ + public const ISSUE__ITEM_OUT_OF_STOCK = 'ITEM_OUT_OF_STOCK'; + public const ISSUE__INSUFFICIENT_INVENTORY = 'INSUFFICIENT_INVENTORY'; + public const ISSUE__BACK_ORDERED = 'BACK_ORDERED'; + public const ISSUE__PRE_ORDER_ONLY = 'PRE_ORDER_ONLY'; + public const ISSUE__ITEM_DISCONTINUED = 'ITEM_DISCONTINUED'; + public const ISSUE__LOW_STOCK_WARNING = 'LOW_STOCK_WARNING'; + public const ISSUE__INVENTORY_RESERVED = 'INVENTORY_RESERVED'; + public const ISSUE__SEASONAL_UNAVAILABLE = 'SEASONAL_UNAVAILABLE'; + public const ISSUE__VARIANT_NOT_AVAILABLE = 'VARIANT_NOT_AVAILABLE'; + public const ISSUE__CUSTOM_OPTION_UNAVAILABLE = 'CUSTOM_OPTION_UNAVAILABLE'; + + /** + * Product item identifier + */ + #[OA\Property(type: 'string')] + protected ?string $itemId = null; + + /** + * Product variant identifier if applicable + */ + #[OA\Property(type: 'string')] + protected ?string $variantId = null; + + /** + * Currently available quantity + */ + #[OA\Property( + type: 'integer', + minimum: 0, + )] + protected ?int $availableQuantity = null; + + #[OA\Property( + type: 'integer', + minimum: 1, + )] + protected ?int $requestedQuantity = null; + + /** + * Quantity reserved for other transactions + */ + #[OA\Property( + type: 'integer', + minimum: 0, + )] + protected ?int $reservedQuantity = null; + + /** + * Expected restock date + */ + #[OA\Property(type: 'string')] + protected ?string $restockDate = null; + + /** + * Estimated shipping date for back-orders + */ + #[OA\Property(type: 'string')] + protected ?string $estimatedShipDate = null; + + /** + * Maximum allowed back-order quantity + */ + #[OA\Property(type: 'integer')] + protected ?int $backOrderLimit = null; + + #[OA\Property(type: 'integer')] + protected ?int $currentBackOrders = null; + + #[OA\Property(type: 'string')] + protected ?string $discontinuationDate = null; + + /** + * Alternative product IDs + * + * @var string[] + */ + #[OA\Property( + type: 'array', + items: new OA\Items(type: 'string'), + )] + protected ?array $suggestedAlternatives = null; + + /** + * Whether newer version is available + */ + #[OA\Property(type: 'boolean')] + protected ?bool $upgradeAvailable = null; + + /** + * When seasonal product becomes available + */ + #[OA\Property(type: 'string')] + protected ?string $seasonalStartDate = null; + + /** + * When item was last sold + */ + #[OA\Property(type: 'string')] + protected ?string $lastSold = null; + + public function getItemId(): ?string + { + return $this->itemId; + } + + public function setItemId(?string $itemId): void + { + $this->itemId = $itemId; + } + + public function getVariantId(): ?string + { + return $this->variantId; + } + + public function setVariantId(?string $variantId): void + { + $this->variantId = $variantId; + } + + public function getAvailableQuantity(): ?int + { + return $this->availableQuantity; + } + + public function setAvailableQuantity(?int $availableQuantity): void + { + if ($availableQuantity < 0) { + $availableQuantity = 0; + } + + $this->availableQuantity = $availableQuantity; + } + + public function getRequestedQuantity(): ?int + { + return $this->requestedQuantity; + } + + public function setRequestedQuantity(?int $requestedQuantity): void + { + if ($requestedQuantity < 1) { + $requestedQuantity = 1; + } + + $this->requestedQuantity = $requestedQuantity; + } + + public function getReservedQuantity(): ?int + { + return $this->reservedQuantity; + } + + public function setReservedQuantity(?int $reservedQuantity): void + { + if ($reservedQuantity < 0) { + $reservedQuantity = 0; + } + + $this->reservedQuantity = $reservedQuantity; + } + + public function getRestockDate(): ?string + { + return $this->restockDate; + } + + public function setRestockDate(?string $restockDate): void + { + $this->restockDate = $restockDate; + } + + public function getEstimatedShipDate(): ?string + { + return $this->estimatedShipDate; + } + + public function setEstimatedShipDate(?string $estimatedShipDate): void + { + $this->estimatedShipDate = $estimatedShipDate; + } + + public function getBackOrderLimit(): ?int + { + return $this->backOrderLimit; + } + + public function setBackOrderLimit(?int $backOrderLimit): void + { + $this->backOrderLimit = $backOrderLimit; + } + + public function getCurrentBackOrders(): ?int + { + return $this->currentBackOrders; + } + + public function setCurrentBackOrders(?int $currentBackOrders): void + { + $this->currentBackOrders = $currentBackOrders; + } + + public function getDiscontinuationDate(): ?string + { + return $this->discontinuationDate; + } + + public function setDiscontinuationDate(?string $discontinuationDate): void + { + $this->discontinuationDate = $discontinuationDate; + } + + /** + * @return ?string[] + */ + public function getSuggestedAlternatives(): ?array + { + return $this->suggestedAlternatives; + } + + /** + * @param ?string[] $suggestedAlternatives + */ + public function setSuggestedAlternatives(?array $suggestedAlternatives): void + { + $this->suggestedAlternatives = $suggestedAlternatives; + } + + public function addSuggestedAlternative(string $suggestedAlternative): void + { + $this->suggestedAlternatives[] = $suggestedAlternative; + } + + public function getUpgradeAvailable(): ?bool + { + return $this->upgradeAvailable; + } + + public function setUpgradeAvailable(?bool $upgradeAvailable): void + { + $this->upgradeAvailable = $upgradeAvailable; + } + + public function getSeasonalStartDate(): ?string + { + return $this->seasonalStartDate; + } + + public function setSeasonalStartDate(?string $seasonalStartDate): void + { + $this->seasonalStartDate = $seasonalStartDate; + } + + public function getLastSold(): ?string + { + return $this->lastSold; + } + + public function setLastSold(?string $lastSold): void + { + $this->lastSold = $lastSold; + } + + protected static function getSpecificIssues(): array + { + return [ + self::ISSUE__ITEM_OUT_OF_STOCK, + self::ISSUE__INSUFFICIENT_INVENTORY, + self::ISSUE__BACK_ORDERED, + self::ISSUE__PRE_ORDER_ONLY, + self::ISSUE__ITEM_DISCONTINUED, + self::ISSUE__LOW_STOCK_WARNING, + self::ISSUE__INVENTORY_RESERVED, + self::ISSUE__SEASONAL_UNAVAILABLE, + self::ISSUE__VARIANT_NOT_AVAILABLE, + self::ISSUE__CUSTOM_OPTION_UNAVAILABLE, + ]; + } +} diff --git a/src/AgentCommerce/Struct/V1/Context/PaymentErrorContext.php b/src/AgentCommerce/Struct/V1/Context/PaymentErrorContext.php new file mode 100644 index 000000000..72370d3a4 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Context/PaymentErrorContext.php @@ -0,0 +1,270 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1\Context; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema(schema: 'paypal_agentic_commerce_v1_context_payment_error_context')] +class PaymentErrorContext extends AbstractContext +{ + public const ISSUE__PAYMENT_AMOUNT_TOO_LARGE = 'PAYMENT_AMOUNT_TOO_LARGE'; + public const ISSUE__PAYMENT_AMOUNT_TOO_SMALL = 'PAYMENT_AMOUNT_TOO_SMALL'; + public const ISSUE__PAYMENT_METHOD_NOT_ACCEPTED = 'PAYMENT_METHOD_NOT_ACCEPTED'; + public const ISSUE__CURRENCY_CONVERSION_FAILED = 'CURRENCY_CONVERSION_FAILED'; + public const ISSUE__PAYMENT_PROCESSOR_UNAVAILABLE = 'PAYMENT_PROCESSOR_UNAVAILABLE'; + public const ISSUE__MERCHANT_ACCOUNT_ISSUE = 'MERCHANT_ACCOUNT_ISSUE'; + public const ISSUE__PAYMENT_DECLINED = 'PAYMENT_DECLINED'; + public const ISSUE__PAYMENT_INSUFFICIENT_FUNDS = 'PAYMENT_INSUFFICIENT_FUNDS'; + public const ISSUE__PAYMENT_EXPIRED = 'PAYMENT_EXPIRED'; + public const ISSUE__PAYMENT_FRAUD_DETECTED = 'PAYMENT_FRAUD_DETECTED'; + + /** + * Total order amount + */ + #[OA\Property(type: 'string')] + protected ?string $orderTotal = null; + + /** + * Maximum payment limit + */ + #[OA\Property(type: 'string')] + protected ?string $paymentLimit = null; + + /** + * Minimum payment amount + */ + #[OA\Property(type: 'string')] + protected ?string $minimumAmount = null; + + /** + * Amount exceeding limit + */ + #[OA\Property(type: 'string')] + protected ?string $excessAmount = null; + + /** + * Payment method being used + */ + #[OA\Property(type: 'string')] + protected ?string $paymentMethod = null; + + /** + * Transaction currency + */ + #[OA\Property(type: 'string')] + protected ?string $currencyCode = null; + + /** + * Source currency for conversion + */ + #[OA\Property(type: 'string')] + protected ?string $fromCurrency = null; + + /** + * Target currency for conversion + */ + #[OA\Property(type: 'string')] + protected ?string $toCurrency = null; + + /** + * Currency conversion service status + */ + #[OA\Property(type: 'string')] + protected ?string $conversionService = null; + + /** + * List of supported payment methods + * + * @var string[] + */ + #[OA\Property( + type: 'array', + items: new OA\Items(type: 'string'), + )] + protected ?array $supportedPaymentMethods = null; + + /** + * Payment processor specific error code + */ + #[OA\Property(type: 'string')] + protected ?string $processorErrorCode = null; + + /** + * Reason for payment decline + */ + #[OA\Property(type: 'string')] + protected ?string $declineReason = null; + + /** + * Payment token that was declined + */ + #[OA\Property(type: 'string')] + protected ?string $paymentToken = null; + + public function getOrderTotal(): ?string + { + return $this->orderTotal; + } + + public function setOrderTotal(?string $orderTotal): void + { + $this->orderTotal = $orderTotal; + } + + public function getPaymentLimit(): ?string + { + return $this->paymentLimit; + } + + public function setPaymentLimit(?string $paymentLimit): void + { + $this->paymentLimit = $paymentLimit; + } + + public function getMinimumAmount(): ?string + { + return $this->minimumAmount; + } + + public function setMinimumAmount(?string $minimumAmount): void + { + $this->minimumAmount = $minimumAmount; + } + + public function getExcessAmount(): ?string + { + return $this->excessAmount; + } + + public function setExcessAmount(?string $excessAmount): void + { + $this->excessAmount = $excessAmount; + } + + public function getPaymentMethod(): ?string + { + return $this->paymentMethod; + } + + public function setPaymentMethod(?string $paymentMethod): void + { + $this->paymentMethod = $paymentMethod; + } + + public function getCurrencyCode(): ?string + { + return $this->currencyCode; + } + + public function setCurrencyCode(?string $currencyCode): void + { + $this->currencyCode = $currencyCode; + } + + public function getFromCurrency(): ?string + { + return $this->fromCurrency; + } + + public function setFromCurrency(?string $fromCurrency): void + { + $this->fromCurrency = $fromCurrency; + } + + public function getToCurrency(): ?string + { + return $this->toCurrency; + } + + public function setToCurrency(?string $toCurrency): void + { + $this->toCurrency = $toCurrency; + } + + public function getConversionService(): ?string + { + return $this->conversionService; + } + + public function setConversionService(?string $conversionService): void + { + $this->conversionService = $conversionService; + } + + /** + * @return ?string[] + */ + public function getSupportedPaymentMethods(): ?array + { + return $this->supportedPaymentMethods; + } + + /** + * @param ?string[] $supportedPaymentMethods + */ + public function setSupportedPaymentMethods(?array $supportedPaymentMethods): void + { + $this->supportedPaymentMethods = $supportedPaymentMethods; + } + + public function addSupportedPaymentMethod(string $supportedPaymentMethod): void + { + $this->supportedPaymentMethods[] = $supportedPaymentMethod; + } + + public function getProcessorErrorCode(): ?string + { + return $this->processorErrorCode; + } + + public function setProcessorErrorCode(?string $processorErrorCode): void + { + $this->processorErrorCode = $processorErrorCode; + } + + public function getDeclineReason(): ?string + { + return $this->declineReason; + } + + public function setDeclineReason(?string $declineReason): void + { + $this->declineReason = $declineReason; + } + + public function getPaymentToken(): ?string + { + return $this->paymentToken; + } + + public function setPaymentToken(?string $paymentToken): void + { + $this->paymentToken = $paymentToken; + } + + protected static function getSpecificIssues(): array + { + return [ + self::ISSUE__PAYMENT_AMOUNT_TOO_LARGE, + self::ISSUE__PAYMENT_AMOUNT_TOO_SMALL, + self::ISSUE__PAYMENT_METHOD_NOT_ACCEPTED, + self::ISSUE__CURRENCY_CONVERSION_FAILED, + self::ISSUE__PAYMENT_PROCESSOR_UNAVAILABLE, + self::ISSUE__MERCHANT_ACCOUNT_ISSUE, + self::ISSUE__PAYMENT_DECLINED, + self::ISSUE__PAYMENT_INSUFFICIENT_FUNDS, + self::ISSUE__PAYMENT_EXPIRED, + self::ISSUE__PAYMENT_FRAUD_DETECTED, + ]; + } +} diff --git a/src/AgentCommerce/Struct/V1/Context/PricingErrorContext.php b/src/AgentCommerce/Struct/V1/Context/PricingErrorContext.php new file mode 100644 index 000000000..f40434841 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Context/PricingErrorContext.php @@ -0,0 +1,406 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1\Context; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; +use Swag\PayPal\AgentCommerce\Struct\V1\Referral\MixedItemCollection; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema(schema: 'paypal_agentic_commerce_v1_context_pricing_error_context')] +class PricingErrorContext extends AbstractContext +{ + public const ISSUE__PRICE_MISMATCH = 'PRICE_MISMATCH'; + public const ISSUE__DISCOUNT_EXPIRED = 'DISCOUNT_EXPIRED'; + public const ISSUE__DISCOUNT_USAGE_LIMIT_EXCEEDED = 'DISCOUNT_USAGE_LIMIT_EXCEEDED'; + public const ISSUE__DISCOUNT_CUSTOMER_INELIGIBLE = 'DISCOUNT_CUSTOMER_INELIGIBLE'; + public const ISSUE__DISCOUNT_MINIMUM_NOT_MET = 'DISCOUNT_MINIMUM_NOT_MET'; + public const ISSUE__TAX_CALCULATION_FAILED = 'TAX_CALCULATION_FAILED'; + public const ISSUE__CURRENCY_NOT_SUPPORTED = 'CURRENCY_NOT_SUPPORTED'; + public const ISSUE__CURRENCY_MISMATCH = 'CURRENCY_MISMATCH'; + public const ISSUE__PROMOTIONAL_CONFLICT = 'PROMOTIONAL_CONFLICT'; + + public const PRICE_CHANGE_REASON__PROMOTIONAL_ENDED = 'promotional_ended'; + public const PRICE_CHANGE_REASON__PROMOTIONAL_STARTED = 'promotional_started'; + public const PRICE_CHANGE_REASON__MARKET_ADJUSTMENT = 'market_adjustment'; + public const PRICE_CHANGE_REASON__COST_INCREASE = 'cost_increase'; + public const PRICE_CHANGE_REASON__SEASONAL_PRICING = 'seasonal_pricing'; + public const PRICE_CHANGE_REASON__COMPONENT_COST_INCREASE = 'component_cost_increase'; + public const PRICE_CHANGE_REASON__TERMS_UPDATED = 'terms_updated'; + + private const PRICE_CHANGED_REASONS = [ + self::PRICE_CHANGE_REASON__PROMOTIONAL_ENDED, + self::PRICE_CHANGE_REASON__PROMOTIONAL_STARTED, + self::PRICE_CHANGE_REASON__MARKET_ADJUSTMENT, + self::PRICE_CHANGE_REASON__COST_INCREASE, + self::PRICE_CHANGE_REASON__SEASONAL_PRICING, + self::PRICE_CHANGE_REASON__COMPONENT_COST_INCREASE, + self::PRICE_CHANGE_REASON__TERMS_UPDATED, + ]; + + /** + * Item with pricing issue + */ + #[OA\Property(type: 'string')] + protected ?string $itemId = null; + + /** + * Original price value + */ + #[OA\Property(type: 'string')] + protected ?string $originalPrice = null; + + /** + * Current price value + */ + #[OA\Property(type: 'string')] + protected ?string $currentPrice = null; + + /** + * Currency code + */ + #[OA\Property(type: 'string')] + protected ?string $currencyCode = null; + + /** + * Reason for price change + */ + #[OA\Property( + type: 'string', + enum: self::PRICE_CHANGED_REASONS, + )] + protected ?string $priceChangeReason = null; + + /** + * Amount of price increase + */ + #[OA\Property(type: 'string')] + protected ?string $priceIncrease = null; + + /** + * Amount of price decrease + */ + #[OA\Property(type: 'string')] + protected ?string $priceDecrease = null; + + /** + * Coupon code with issues + */ + #[OA\Property(type: 'string')] + protected ?string $couponCode = null; + + /** + * Coupon usage limit + */ + #[OA\Property(type: 'integer')] + protected ?int $usageLimit = null; + + /** + * Current coupon usage count + */ + #[OA\Property(type: 'integer')] + protected ?int $currentUsage = null; + + /** + * Discount expiration date + */ + #[OA\Property(type: 'string')] + protected ?string $expirationDate = null; + + /** + * Minimum order for discount + */ + #[OA\Property(type: 'string')] + protected ?string $minimumOrderAmount = null; + + /** + * List of supported currencies + * + * @var string[] + */ + #[OA\Property( + type: 'array', + items: new OA\Items(type: 'string'), + )] + protected ?array $supportedCurrencies = null; + + /** + * Multiple currencies found in cart + * + * @var string[] + */ + #[OA\Property( + type: 'array', + items: new OA\Items(type: 'string'), + )] + protected ?array $foundCurrencies = null; + + /** + * Tax calculation service error + */ + #[OA\Property(type: 'string')] + protected ?string $taxServiceError = null; + + /** + * Current system date for comparisons + */ + #[OA\Property(type: 'string')] + protected ?string $currentDate = null; + + /** + * Discount amount that was applied + */ + #[OA\Property(type: 'string')] + protected ?string $discountAmount = null; + + /** + * Whether all items must use same currency + */ + #[OA\Property(type: 'boolean')] + protected ?bool $requiredCurrencyConsistency = null; + + /** + * Items with different currencies + */ + #[OA\Property(ref: MixedItemCollection::class)] + protected MixedItemCollection $mixedItems; + + public function getItemId(): ?string + { + return $this->itemId; + } + + public function setItemId(?string $itemId): void + { + $this->itemId = $itemId; + } + + public function getOriginalPrice(): ?string + { + return $this->originalPrice; + } + + public function setOriginalPrice(?string $originalPrice): void + { + $this->originalPrice = $originalPrice; + } + + public function getCurrentPrice(): ?string + { + return $this->currentPrice; + } + + public function setCurrentPrice(?string $currentPrice): void + { + $this->currentPrice = $currentPrice; + } + + public function getCurrencyCode(): ?string + { + return $this->currencyCode; + } + + public function setCurrencyCode(?string $currencyCode): void + { + $this->currencyCode = $currencyCode; + } + + public function getPriceChangeReason(): ?string + { + return $this->priceChangeReason; + } + + public function setPriceChangeReason(?string $priceChangeReason): void + { + if (!\in_array($priceChangeReason, self::PRICE_CHANGED_REASONS, true)) { + throw new \InvalidArgumentException(\sprintf('Price change reason "%s" is not valid.', $priceChangeReason)); + } + + $this->priceChangeReason = $priceChangeReason; + } + + public function getPriceIncrease(): ?string + { + return $this->priceIncrease; + } + + public function setPriceIncrease(?string $priceIncrease): void + { + $this->priceIncrease = $priceIncrease; + } + + public function getPriceDecrease(): ?string + { + return $this->priceDecrease; + } + + public function setPriceDecrease(?string $priceDecrease): void + { + $this->priceDecrease = $priceDecrease; + } + + public function getCouponCode(): ?string + { + return $this->couponCode; + } + + public function setCouponCode(?string $couponCode): void + { + $this->couponCode = $couponCode; + } + + public function getUsageLimit(): ?int + { + return $this->usageLimit; + } + + public function setUsageLimit(?int $usageLimit): void + { + $this->usageLimit = $usageLimit; + } + + public function getCurrentUsage(): ?int + { + return $this->currentUsage; + } + + public function setCurrentUsage(?int $currentUsage): void + { + $this->currentUsage = $currentUsage; + } + + public function getExpirationDate(): ?string + { + return $this->expirationDate; + } + + public function setExpirationDate(?string $expirationDate): void + { + $this->expirationDate = $expirationDate; + } + + public function getMinimumOrderAmount(): ?string + { + return $this->minimumOrderAmount; + } + + public function setMinimumOrderAmount(?string $minimumOrderAmount): void + { + $this->minimumOrderAmount = $minimumOrderAmount; + } + + /** + * @return ?string[] + */ + public function getSupportedCurrencies(): ?array + { + return $this->supportedCurrencies; + } + + /** + * @param ?string[] $supportedCurrencies + */ + public function setSupportedCurrencies(?array $supportedCurrencies): void + { + $this->supportedCurrencies = $supportedCurrencies; + } + + public function addSupportedCurrency(string $supportedCurrency): void + { + $this->supportedCurrencies[] = $supportedCurrency; + } + + /** + * @return ?string[] + */ + public function getFoundCurrencies(): ?array + { + return $this->foundCurrencies; + } + + /** + * @param ?string[] $foundCurrencies + */ + public function setFoundCurrencies(?array $foundCurrencies): void + { + $this->foundCurrencies = $foundCurrencies; + } + + public function addFoundCurrency(string $foundCurrency): void + { + $this->foundCurrencies[] = $foundCurrency; + } + + public function getTaxServiceError(): ?string + { + return $this->taxServiceError; + } + + public function setTaxServiceError(?string $taxServiceError): void + { + $this->taxServiceError = $taxServiceError; + } + + public function getCurrentDate(): ?string + { + return $this->currentDate; + } + + public function setCurrentDate(?string $currentDate): void + { + $this->currentDate = $currentDate; + } + + public function getDiscountAmount(): ?string + { + return $this->discountAmount; + } + + public function setDiscountAmount(?string $discountAmount): void + { + $this->discountAmount = $discountAmount; + } + + public function getRequiredCurrencyConsistency(): ?bool + { + return $this->requiredCurrencyConsistency; + } + + public function setRequiredCurrencyConsistency(?bool $requiredCurrencyConsistency): void + { + $this->requiredCurrencyConsistency = $requiredCurrencyConsistency; + } + + public function getMixedItems(): MixedItemCollection + { + return $this->mixedItems; + } + + public function setMixedItems(MixedItemCollection $mixedItems): void + { + $this->mixedItems = $mixedItems; + } + + protected static function getSpecificIssues(): array + { + return [ + self::ISSUE__PRICE_MISMATCH, + self::ISSUE__DISCOUNT_EXPIRED, + self::ISSUE__DISCOUNT_USAGE_LIMIT_EXCEEDED, + self::ISSUE__DISCOUNT_CUSTOMER_INELIGIBLE, + self::ISSUE__DISCOUNT_MINIMUM_NOT_MET, + self::ISSUE__TAX_CALCULATION_FAILED, + self::ISSUE__CURRENCY_NOT_SUPPORTED, + self::ISSUE__CURRENCY_MISMATCH, + self::ISSUE__PROMOTIONAL_CONFLICT, + ]; + } +} diff --git a/src/AgentCommerce/Struct/V1/Context/ShippingErrorContext.php b/src/AgentCommerce/Struct/V1/Context/ShippingErrorContext.php new file mode 100644 index 000000000..7a7b2c87f --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Context/ShippingErrorContext.php @@ -0,0 +1,292 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1\Context; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; +use Swag\PayPal\AgentCommerce\Struct\V1\Referral\SuggestedCorrection; +use Swag\PayPal\AgentCommerce\Struct\V1\Referral\SuggestedCorrectionCollection; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema(schema: 'paypal_agentic_commerce_v1_context_shipping_error_context')] +class ShippingErrorContext extends AbstractContext +{ + public const ISSUE__MISSING_SHIPPING_ADDRESS = 'MISSING_SHIPPING_ADDRESS'; + public const ISSUE__SHIPPING_ADDRESS_INVALID = 'SHIPPING_ADDRESS_INVALID'; + public const ISSUE__SHIPPING_TO_PO_BOX_NOT_ALLOWED = 'SHIPPING_TO_PO_BOX_NOT_ALLOWED'; + public const ISSUE__NO_SHIPPING_OPTIONS = 'NO_SHIPPING_OPTIONS'; + public const ISSUE__INTERNATIONAL_SHIPPING_RESTRICTED = 'INTERNATIONAL_SHIPPING_RESTRICTED'; + public const ISSUE__REGION_RESTRICTED = 'REGION_RESTRICTED'; + public const ISSUE__OVERSIZED_ITEM_SHIPPING = 'OVERSIZED_ITEM_SHIPPING'; + public const ISSUE__HAZARDOUS_MATERIAL_SHIPPING = 'HAZARDOUS_MATERIAL_SHIPPING'; + public const ISSUE__SHIPPING_ZONE_NOT_COVERED = 'SHIPPING_ZONE_NOT_COVERED'; + public const ISSUE__MISSING_COORDINATES_FOR_ENHANCED_DELIVERY = 'MISSING_COORDINATES_FOR_ENHANCED_DELIVERY'; + + public const RESTRICTED_REASON__SIGNATURE_REQUIRED = 'signature_required'; + public const RESTRICTED_REASON__AGE_VERIFICATION_REQUIRED = 'age_verification_required'; + public const RESTRICTED_REASON__EXPORT_CONTROLLED = 'export_controlled'; + public const RESTRICTED_REASON__HAZARDOUS_MATERIAL = 'hazardous_material'; + public const RESTRICTED_REASON__OVERSIZED_ITEM = 'oversized_item'; + public const RESTRICTED_REASON__PO_BOX_RESTRICTION = 'po_box_restriction'; + + public const RESTRICTED_REASONS = [ + self::RESTRICTED_REASON__SIGNATURE_REQUIRED, + self::RESTRICTED_REASON__AGE_VERIFICATION_REQUIRED, + self::RESTRICTED_REASON__EXPORT_CONTROLLED, + self::RESTRICTED_REASON__HAZARDOUS_MATERIAL, + self::RESTRICTED_REASON__OVERSIZED_ITEM, + self::RESTRICTED_REASON__PO_BOX_RESTRICTION, + ]; + + /** + * Specific address validation failures + * + * @var string[] + */ + #[OA\Property( + type: 'array', + items: new OA\Items(type: 'string'), + )] + protected ?array $validationFailures = null; + + /** + * Suggested address corrections + */ + #[OA\Property(ref: SuggestedCorrectionCollection::class)] + protected SuggestedCorrectionCollection $suggestedCorrections; + + /** + * Address validation quality score + */ + #[OA\Property( + type: 'float', + maximum: 1, + minimum: 0, + )] + protected ?float $addressQualityScore = null; + + /** + * Items with shipping restrictions + * + * @var string[] + */ + #[OA\Property( + type: 'array', + items: new OA\Items(type: 'string'), + )] + protected ?array $restrictedItems = null; + + /** + * Reason for shipping restriction + */ + #[OA\Property( + type: 'string', + enum: self::RESTRICTED_REASONS, + )] + protected ?string $restrictionReason = null; + + /** + * Whether PO Box was detected + */ + #[OA\Property(type: 'boolean')] + protected ?bool $poBoxDetected = null; + + /** + * Destination country code + */ + #[OA\Property(type: 'string')] + protected ?string $destinationCountry = null; + + /** + * Restricted region identifier + */ + #[OA\Property(type: 'string')] + protected ?string $restrictedRegion = null; + + /** + * List of supported countries + * + * @var string[] + */ + #[OA\Property( + type: 'array', + items: new OA\Items(type: 'string'), + )] + protected ?array $supportedCountries = null; + + /** + * Address string that failed validation + */ + #[OA\Property(type: 'string')] + protected ?string $providedAddress = null; + + /** + * @return ?string[] + */ + public function getValidationFailures(): ?array + { + return $this->validationFailures; + } + + /** + * @param ?string[] $validationFailures + */ + public function setValidationFailures(?array $validationFailures): void + { + $this->validationFailures = $validationFailures; + } + + public function addValidationFailure(string $validationFailure): void + { + $this->validationFailures[] = $validationFailure; + } + + public function getSuggestedCorrections(): SuggestedCorrectionCollection + { + return $this->suggestedCorrections; + } + + public function setSuggestedCorrections(SuggestedCorrectionCollection $suggestedCorrections): void + { + $this->suggestedCorrections = $suggestedCorrections; + } + + public function addSuggestedCorrection(SuggestedCorrection $suggestedCorrection): void + { + $this->suggestedCorrections->add($suggestedCorrection); + } + + public function getAddressQualityScore(): ?float + { + return $this->addressQualityScore; + } + + public function setAddressQualityScore(?float $addressQualityScore): void + { + if ($addressQualityScore < 0 || $addressQualityScore > 1) { + throw new \InvalidArgumentException(\sprintf('Address quality score "%s" is not valid. Must be between 0 and 1', $addressQualityScore)); + } + + $this->addressQualityScore = $addressQualityScore; + } + + /** + * @return ?string[] + */ + public function getRestrictedItems(): ?array + { + return $this->restrictedItems; + } + + /** + * @param ?string[] $restrictedItems + */ + public function setRestrictedItems(?array $restrictedItems): void + { + $this->restrictedItems = $restrictedItems; + } + + public function addRestrictedItem(string $restrictedItem): void + { + $this->restrictedItems[] = $restrictedItem; + } + + public function getRestrictionReason(): ?string + { + return $this->restrictionReason; + } + + public function setRestrictionReason(string $restrictionReason): void + { + if (!\in_array($restrictionReason, self::RESTRICTED_REASONS, true)) { + throw new \InvalidArgumentException(\sprintf('Restricted reason "%s" is not valid.', $restrictionReason)); + } + + $this->restrictionReason = $restrictionReason; + } + + public function getPoBoxDetected(): ?bool + { + return $this->poBoxDetected; + } + + public function setPoBoxDetected(?bool $poBoxDetected): void + { + $this->poBoxDetected = $poBoxDetected; + } + + public function getDestinationCountry(): ?string + { + return $this->destinationCountry; + } + + public function setDestinationCountry(?string $destinationCountry): void + { + $this->destinationCountry = $destinationCountry; + } + + public function getRestrictedRegion(): ?string + { + return $this->restrictedRegion; + } + + public function setRestrictedRegion(?string $restrictedRegion): void + { + $this->restrictedRegion = $restrictedRegion; + } + + /** + * @return ?string[] + */ + public function getSupportedCountries(): ?array + { + return $this->supportedCountries; + } + + /** + * @param ?string[] $supportedCountries + */ + public function setSupportedCountries(?array $supportedCountries): void + { + $this->supportedCountries = $supportedCountries; + } + + public function addSupportedCountries(string $supportedCountry): void + { + $this->supportedCountries[] = $supportedCountry; + } + + public function getProvidedAddress(): ?string + { + return $this->providedAddress; + } + + public function setProvidedAddress(?string $providedAddress): void + { + $this->providedAddress = $providedAddress; + } + + protected static function getSpecificIssues(): array + { + return [ + self::ISSUE__MISSING_SHIPPING_ADDRESS, + self::ISSUE__SHIPPING_ADDRESS_INVALID, + self::ISSUE__SHIPPING_TO_PO_BOX_NOT_ALLOWED, + self::ISSUE__NO_SHIPPING_OPTIONS, + self::ISSUE__INTERNATIONAL_SHIPPING_RESTRICTED, + self::ISSUE__REGION_RESTRICTED, + self::ISSUE__OVERSIZED_ITEM_SHIPPING, + self::ISSUE__HAZARDOUS_MATERIAL_SHIPPING, + self::ISSUE__SHIPPING_ZONE_NOT_COVERED, + self::ISSUE__MISSING_COORDINATES_FOR_ENHANCED_DELIVERY, + ]; + } +} diff --git a/src/AgentCommerce/Struct/V1/Coupon.php b/src/AgentCommerce/Struct/V1/Coupon.php new file mode 100644 index 000000000..8ca5889b1 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Coupon.php @@ -0,0 +1,76 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Struct; + +/** + * @experimental + * + * Discount coupon for cart operations. Multiple coupons can be applied simultaneously, with merchant business rules determining stacking behavior, priorities, and conflicts. + * + * Common Scenarios: + * + * Apply multiple discount codes (percentage + fixed amount) + * Stack loyalty discounts with promotional codes + * Remove specific coupons while keeping others + * Apply category-specific and store-wide discounts together + * + * Business Rules: Merchants define stacking rules, maximum discounts, exclusions, and priority orders. Invalid combinations return validation issues with suggested resolutions. + */ +#[Package('checkout')] +#[OA\Schema( + schema: 'paypal_agentic_commerce_v1_coupon', + required: ['code', 'action'] +)] +class Coupon extends Struct +{ + public const APPLY = 'APPLY'; + public const REMOVE = 'REMOVE'; + + /** + * Coupon code identifier + */ + #[OA\Property(type: 'string')] + protected string $code; + + /** + * Action to perform on this specific coupon + */ + #[OA\Property( + type: 'string', + enum: [self::APPLY, self::REMOVE] + )] + protected string $action; + + public function getAction(): string + { + return $this->action; + } + + public function setAction(string $action): void + { + if (!\in_array($action, [self::APPLY, self::REMOVE], true)) { + throw new \InvalidArgumentException(\sprintf('Action "%s" is not valid.', $action)); + } + + $this->action = $action; + } + + public function getCode(): string + { + return $this->code; + } + + public function setCode(string $code): void + { + $this->code = $code; + } +} diff --git a/src/AgentCommerce/Struct/V1/CouponCollection.php b/src/AgentCommerce/Struct/V1/CouponCollection.php new file mode 100644 index 000000000..985eb92c9 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/CouponCollection.php @@ -0,0 +1,25 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1; + +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Collection; + +/** + * @experimental + * + * @extends Collection + */ +#[Package('checkout')] +class CouponCollection extends Collection +{ + protected function getExpectedClass(): string + { + return Coupon::class; + } +} diff --git a/src/AgentCommerce/Struct/V1/Customer.php b/src/AgentCommerce/Struct/V1/Customer.php new file mode 100644 index 000000000..76c7a53ca --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Customer.php @@ -0,0 +1,76 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Struct; +use Swag\PayPal\AgentCommerce\Struct\V1\Referral\CustomerName; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema(schema: 'paypal_agentic_commerce_v1_customer')] +class Customer extends Struct +{ + #[OA\Property(ref: CustomerName::class)] + protected CustomerName $name; + + #[OA\Property(ref: Phone::class)] + protected ?Phone $phone = null; + + /** + * The internationalized email address. + * Note: Up to 64 characters are allowed before and 255 characters are allowed after the @ sign. + * However, the generally accepted maximum length for an email address is 254 characters. + * The pattern verifies that an unquoted @ sign exists. + */ + #[OA\Property( + type: 'string', + maxLength: 254, + minLength: 3, + pattern: '^(?:[A-Za-z0-9!#$%&\'*+/=?^_`{|}~-]+(?:\.[A-Za-z0-9!#$%&\'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\.)+[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[A-Za-z0-9-]*[A-Za-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])$' + )] + protected ?string $emailAddress = null; + + public function getName(): CustomerName + { + return $this->name; + } + + public function setName(CustomerName $name): void + { + $this->name = $name; + } + + public function getPhone(): ?Phone + { + return $this->phone; + } + + public function setPhone(?Phone $phone): void + { + $this->phone = $phone; + } + + public function getEmailAddress(): ?string + { + return $this->emailAddress; + } + + public function setEmailAddress(?string $emailAddress): void + { + $this->emailAddress = $emailAddress; + } + + public function jsonSerialize(): array + { + return \array_filter(parent::jsonSerialize()); + } +} diff --git a/src/AgentCommerce/Struct/V1/Error.php b/src/AgentCommerce/Struct/V1/Error.php new file mode 100644 index 000000000..cdd0869fb --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Error.php @@ -0,0 +1,92 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Struct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema( + schema: 'paypal_agentic_commerce_v1_error', + required: ['name', 'message'] +)] +class Error extends Struct +{ + /** + * Error name/type + */ + #[OA\Property(type: 'string')] + protected string $name; + + /** + * Error description + */ + #[OA\Property(type: 'string')] + protected string $message; + + /** + * Unique error identifier for support + */ + #[OA\Property(type: 'string')] + protected ?string $debugId = null; + + /** + * Detailed error information + */ + #[OA\Property(ref: AgentErrorDetailCollection::class)] + protected AgentErrorDetailCollection $details; + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public function getMessage(): string + { + return $this->message; + } + + public function setMessage(string $message): void + { + $this->message = $message; + } + + public function getDebugId(): ?string + { + return $this->debugId; + } + + public function setDebugId(?string $debugId): void + { + $this->debugId = $debugId; + } + + public function getDetails(): AgentErrorDetailCollection + { + return $this->details; + } + + public function setDetails(AgentErrorDetailCollection $details): void + { + $this->details = $details; + } + + public function jsonSerialize(): array + { + return \array_filter(parent::jsonSerialize()); + } +} diff --git a/src/AgentCommerce/Struct/V1/GeoCoordinates.php b/src/AgentCommerce/Struct/V1/GeoCoordinates.php new file mode 100644 index 000000000..3bf43bf9c --- /dev/null +++ b/src/AgentCommerce/Struct/V1/GeoCoordinates.php @@ -0,0 +1,106 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Struct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema(schema: 'paypal_agentic_commerce_v1_geo_coordinates')] +class GeoCoordinates extends Struct +{ + /** + * Latitude coordinate in decimal degrees (-90 to 90). WGS84 datum. + */ + #[OA\Property( + type: 'string', + pattern: '^-?([1-8]?[0-9](\.\d+)?|90(\.0+)?)$' + )] + protected ?string $latitude = null; + + /** + * Longitude coordinate in decimal degrees (-180 to 180). WGS84 datum. + */ + #[OA\Property( + type: 'string', + pattern: '^-?((1[0-7]|[1-9])?[0-9](\.\d+)?|180(\.0+)?)$' + )] + protected ?string $longitude = null; + + /** + * Administrative subdivision code (state, province, region). + * ISO 3166-2 format without country prefix (e.g., 'CA' for California, 'ON' for Ontario). + */ + #[OA\Property( + type: 'string', + maxLength: 10, + minLength: 1, + pattern: '^[A-Z0-9-]+$' + )] + protected ?string $subdivision = null; + + /** + * ISO 3166-1 alpha-2 country code for the coordinate location. + */ + #[OA\Property( + type: 'string', + maxLength: 2, + minLength: 2, + pattern: '^[A-Z]{2}$' + )] + protected ?string $countryCode = null; + + public function getLatitude(): ?string + { + return $this->latitude; + } + + public function setLatitude(?string $latitude): void + { + $this->latitude = $latitude; + } + + public function getLongitude(): ?string + { + return $this->longitude; + } + + public function setLongitude(?string $longitude): void + { + $this->longitude = $longitude; + } + + public function getSubdivision(): ?string + { + return $this->subdivision; + } + + public function setSubdivision(?string $subdivision): void + { + $this->subdivision = $subdivision; + } + + public function getCountryCode(): ?string + { + return $this->countryCode; + } + + public function setCountryCode(?string $countryCode): void + { + $this->countryCode = $countryCode; + } + + public function jsonSerialize(): array + { + return \array_filter(parent::jsonSerialize()); + } +} diff --git a/src/AgentCommerce/Struct/V1/GiftOptions.php b/src/AgentCommerce/Struct/V1/GiftOptions.php new file mode 100644 index 000000000..5b1438602 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/GiftOptions.php @@ -0,0 +1,132 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Struct; +use Swag\PayPal\AgentCommerce\Struct\V1\Referral\Recipient; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema(schema: 'paypal_agentic_commerce_v1_gift_options')] +class GiftOptions extends Struct +{ + /** + * Whether this is a gift + */ + #[OA\Property(type: 'boolean')] + protected bool $isGift = false; + + /** + * Gift recipient information + */ + #[OA\Property(ref: Recipient::class)] + protected ?Recipient $recipient = null; + + /** + * Scheduled delivery date in RFC3339 format. Seconds are required while fractional seconds are optional. + * + * example: 2024-12-25T09:00:00Z + */ + #[OA\Property( + type: 'string', + maxLength: 64, + minLength: 20, + pattern: '^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])[T,t]([0-1][0-9]|2[0-3]):[0-5][0-9]:([0-5][0-9]|60)([.][0-9]+)?([Zz]|[+-][0-9]{2}:[0-9]{2})$' + )] + protected ?string $deliveryDate = null; + + /** + * Name of gift sender + */ + #[OA\Property(type: 'string')] + protected ?string $senderName = null; + + /** + * Personal message (max 500 characters) + */ + #[OA\Property( + type: 'string', + maxLength: 500, + )] + protected ?string $giftMessage = null; + + /** + * Whether to include gift wrapping + */ + #[OA\Property(type: 'boolean')] + protected ?bool $giftWrap = null; + + public function isGift(): bool + { + return $this->isGift; + } + + public function setIsGift(bool $isGift): void + { + $this->isGift = $isGift; + } + + public function getRecipient(): ?Recipient + { + return $this->recipient; + } + + public function setRecipient(?Recipient $recipient): void + { + $this->recipient = $recipient; + } + + public function getDeliveryDate(): ?string + { + return $this->deliveryDate; + } + + public function setDeliveryDate(?string $deliveryDate): void + { + $this->deliveryDate = $deliveryDate; + } + + public function getSenderName(): ?string + { + return $this->senderName; + } + + public function setSenderName(?string $senderName): void + { + $this->senderName = $senderName; + } + + public function getGiftMessage(): ?string + { + return $this->giftMessage; + } + + public function setGiftMessage(?string $giftMessage): void + { + $this->giftMessage = $giftMessage; + } + + public function getGiftWrap(): ?bool + { + return $this->giftWrap; + } + + public function setGiftWrap(?bool $giftWrap): void + { + $this->giftWrap = $giftWrap; + } + + public function jsonSerialize(): array + { + return \array_filter(parent::jsonSerialize()); + } +} diff --git a/src/AgentCommerce/Struct/V1/Link.php b/src/AgentCommerce/Struct/V1/Link.php new file mode 100644 index 000000000..f81c22bd1 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Link.php @@ -0,0 +1,132 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Struct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema( + schema: 'paypal_agentic_commerce_v1_link', + required: ['rel', 'href'] +)] +class Link extends Struct +{ + public const REL__SELF = 'rel'; + public const REL__UPDATE = 'update'; + public const REL__CHECKOUT = 'checkout'; + + public const METHOD__GET = 'GET'; + public const METHOD__POST = 'POST'; + public const METHOD__PUT = 'PUT'; + + /** + * Link relationship type + */ + #[OA\Property( + type: 'string', + enum: [self::REL__SELF, self::REL__UPDATE, self::REL__CHECKOUT] + )] + protected string $rel; + + /** + * Target URL for the link + * + * example: https://your-domain.com/api/paypal/v1/merchant-cart/CART-123 + */ + #[OA\Property(type: 'string')] + protected string $href; + + /** + * HTTP method for the link + */ + #[OA\Property( + type: 'string', + enum: [self::METHOD__GET, self::METHOD__POST, self::METHOD__PUT], + )] + protected ?string $method = null; + + /** + * Human-readable description of the link + */ + #[OA\Property(type: 'string')] + protected ?string $title = null; + + /** + * Expected content type + */ + #[OA\Property(type: 'string')] + protected ?string $type = null; + + public function getRel(): string + { + return $this->rel; + } + + public function setRel(string $rel): void + { + if (!\in_array($rel, [self::REL__SELF, self::REL__UPDATE, self::REL__CHECKOUT], true)) { + throw new \InvalidArgumentException(\sprintf('Rel "%s" is not valid.', $rel)); + } + + $this->rel = $rel; + } + + public function getHref(): string + { + return $this->href; + } + + public function setHref(string $href): void + { + $this->href = $href; + } + + public function getMethod(): ?string + { + return $this->method; + } + + public function setMethod(?string $method): void + { + if (!\in_array($method, [self::METHOD__GET, self::METHOD__POST, self::METHOD__PUT], true)) { + throw new \InvalidArgumentException(\sprintf('Method "%s" is not valid.', $method)); + } + + $this->method = $method; + } + + public function getTitle(): ?string + { + return $this->title; + } + + public function setTitle(?string $title): void + { + $this->title = $title; + } + + public function getType(): ?string + { + return $this->type; + } + + public function setType(?string $type): void + { + $this->type = $type; + } + + public function jsonSerialize(): array + { + return \array_filter(parent::jsonSerialize()); + } +} diff --git a/src/AgentCommerce/Struct/V1/LinkCollection.php b/src/AgentCommerce/Struct/V1/LinkCollection.php new file mode 100644 index 000000000..9107f3212 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/LinkCollection.php @@ -0,0 +1,25 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1; + +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Collection; + +/** + * @experimental + * + * @extends Collection + */ +#[Package('checkout')] +class LinkCollection extends Collection +{ + protected function getExpectedClass(): string + { + return Link::class; + } +} diff --git a/src/AgentCommerce/Struct/V1/Money.php b/src/AgentCommerce/Struct/V1/Money.php new file mode 100644 index 000000000..eaf862f7d --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Money.php @@ -0,0 +1,77 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Struct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema( + schema: 'paypal_agentic_commerce_v1_money', + required: ['currencyCode', 'value'] +)] +class Money extends Struct +{ + /** + * The 3-character ISO-4217 currency code that identifies the currency. + */ + #[OA\Property( + type: 'string', + maxLength: 3, + minLength: 3, + pattern: '^[\S\s]*$' + )] + protected string $currencyCode; + + /** + * The value, which might be: An integer for currencies like JPY that are not typically fractional. A decimal fraction for currencies like TND that are subdivided into thousandths. For the required number of decimal places for a currency code, see Currency Codes. + * + * @var numeric-string + */ + #[OA\Property( + type: 'string', + maxLength: 0, + minLength: 32, + pattern: '^((-?[0-9]+)|(-?([0-9]+)?[.][0-9]+))$' + )] + protected string $value; + + public function getCurrencyCode(): string + { + return $this->currencyCode; + } + + public function setCurrencyCode(string $currencyCode): void + { + if (\strlen($currencyCode) !== 3) { + throw new \InvalidArgumentException(\sprintf('Currency code "%s" is not valid.', $currencyCode)); + } + + $this->currencyCode = $currencyCode; + } + + /** + * @return numeric-string + */ + public function getValue(): string + { + return $this->value; + } + + /** + * @param numeric-string $value + */ + public function setValue(string $value): void + { + $this->value = $value; + } +} diff --git a/src/AgentCommerce/Struct/V1/PayPalCart.php b/src/AgentCommerce/Struct/V1/PayPalCart.php new file mode 100644 index 000000000..b13444d35 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/PayPalCart.php @@ -0,0 +1,304 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Struct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema( + schema: 'paypal_agentic_commerce_v1_pay_pal_cart', + required: ['items', 'paymentMethod'] +)] +class PayPalCart extends Struct +{ + public const STATUS__CREATED = 'CREATED'; + public const STATUS__INCOMPLETE = 'INCOMPLETE'; + public const STATUS__READY = 'READY'; + public const STATUS__COMPLETE = 'COMPLETE'; + + /** + * Cart data is complete and valid, ready for checkout + * + * All items are available with current pricing + * Required information is complete (shipping address, customer data) + * No business rule violations + * validation_issues array is empty + * Can proceed directly to checkout + */ + public const VALIDATION_STATUS__VALID = 'VALID'; + + /** + * Cart has data issues that prevent checkout + * + * Items out of stock, price changes, business rule violations + * Invalid or incomplete data that needs correction + * validation_issues array contains specific problems + * Customer/AI agent must resolve issues before checkout + */ + public const VALIDATION_STATUS__INVALID = 'INVALID'; + + /** + * Cart needs more data but is otherwise valid + * + * Missing optional but required fields (shipping address, checkout fields) + * Age verification, custom fields, delivery preferences needed + * Items and pricing are valid, just needs customer input + * validation_issues array indicates what information is needed + */ + public const VALIDATION_STATUS__REQUIRES_ADDITIONAL_INFORMATION = 'REQUIRES_ADDITIONAL_INFORMATION'; + + private const STATUSES = [self::STATUS__CREATED, self::STATUS__COMPLETE, self::STATUS__READY, self::STATUS__INCOMPLETE]; + private const VALIDATION_STATUSES = [self::VALIDATION_STATUS__VALID, self::VALIDATION_STATUS__INVALID, self::VALIDATION_STATUS__REQUIRES_ADDITIONAL_INFORMATION]; + + #[OA\Property(type: 'string', readOnly: true)] + protected string $id; + + #[OA\Property( + type: 'string', + enum: self::STATUSES, + readOnly: true, + )] + protected string $status; + + #[OA\Property( + type: 'string', + enum: self::VALIDATION_STATUSES, + readOnly: true, + )] + protected string $validationStatus; + + /** + * List of issues preventing checkout (empty = ready) + */ + #[OA\Property(ref: ValidationIssueCollection::class)] + protected ValidationIssueCollection $validationIssues; + + #[OA\Property(ref: CartTotals::class)] + protected ?CartTotals $totals = null; + + /** + * Successfully applied coupons (server-calculated) + */ + #[OA\Property(ref: AppliedCouponCollection::class)] + protected ?AppliedCouponCollection $appliedCoupons = null; + + /** + * Available shipping methods with selection state + */ + #[OA\Property(ref: ShippingOptionCollection::class)] + protected ?ShippingOptionCollection $availableShippingOptions = null; + + /** + * HATEOAS navigation links for cart operations + */ + #[OA\Property(ref: LinkCollection::class)] + protected ?LinkCollection $links = null; + + /** + * Products in the cart + */ + #[OA\Property(ref: CartItemCollection::class)] + protected CartItemCollection $items; + + #[OA\Property(ref: Customer::class)] + protected ?Customer $customer = null; + + #[OA\Property(ref: ShippingAddress::class)] + protected ?ShippingAddress $shippingAddress = null; + + #[OA\Property(ref: BillingAddress::class)] + protected ?BillingAddress $billingAddress = null; + + #[OA\Property(ref: PaymentMethod::class)] + protected ?PaymentMethod $paymentMethod = null; + + /** + * Custom checkout fields (age verification, etc.) + */ + #[OA\Property(ref: CheckoutFieldCollection::class)] + protected ?CheckoutFieldCollection $checkoutFields = null; + + /** + * Discount coupons to apply or remove from cart + */ + #[OA\Property(ref: CouponCollection::class)] + protected CouponCollection $coupons; + + #[OA\Property(ref: GeoCoordinates::class)] + protected ?GeoCoordinates $geoCoordinates = null; + + public function getId(): string + { + return $this->id; + } + + public function setId(string $id): void + { + $this->id = $id; + } + + public function getStatus(): string + { + return $this->status; + } + + public function setStatus(string $status): void + { + $this->status = $status; + } + + public function getValidationStatus(): string + { + return $this->validationStatus; + } + + public function setValidationStatus(string $validationStatus): void + { + $this->validationStatus = $validationStatus; + } + + public function getValidationIssues(): ValidationIssueCollection + { + return $this->validationIssues; + } + + public function setValidationIssues(ValidationIssueCollection $validationIssues): void + { + $this->validationIssues = $validationIssues; + } + + public function getAppliedCoupons(): ?AppliedCouponCollection + { + return $this->appliedCoupons; + } + + public function setAppliedCoupons(?AppliedCouponCollection $appliedCoupons): void + { + $this->appliedCoupons = $appliedCoupons; + } + + public function getTotals(): ?CartTotals + { + return $this->totals; + } + + public function setTotals(?CartTotals $totals): void + { + $this->totals = $totals; + } + + public function getAvailableShippingOptions(): ?ShippingOptionCollection + { + return $this->availableShippingOptions; + } + + public function setAvailableShippingOptions(?ShippingOptionCollection $availableShippingOptions): void + { + $this->availableShippingOptions = $availableShippingOptions; + } + + public function getLinks(): ?LinkCollection + { + return $this->links; + } + + public function setLinks(?LinkCollection $links): void + { + $this->links = $links; + } + + public function getItems(): CartItemCollection + { + return $this->items; + } + + public function setItems(CartItemCollection $items): void + { + $this->items = $items; + } + + public function getCustomer(): ?Customer + { + return $this->customer; + } + + public function setCustomer(?Customer $customer): void + { + $this->customer = $customer; + } + + public function getShippingAddress(): ?ShippingAddress + { + return $this->shippingAddress; + } + + public function setShippingAddress(?ShippingAddress $shippingAddress): void + { + $this->shippingAddress = $shippingAddress; + } + + public function getBillingAddress(): ?BillingAddress + { + return $this->billingAddress; + } + + public function setBillingAddress(?BillingAddress $billingAddress): void + { + $this->billingAddress = $billingAddress; + } + + public function getPaymentMethod(): ?PaymentMethod + { + return $this->paymentMethod; + } + + public function setPaymentMethod(?PaymentMethod $paymentMethod): void + { + $this->paymentMethod = $paymentMethod; + } + + public function getCheckoutFields(): ?CheckoutFieldCollection + { + return $this->checkoutFields; + } + + public function setCheckoutFields(?CheckoutFieldCollection $checkoutFields): void + { + $this->checkoutFields = $checkoutFields; + } + + public function getCoupons(): CouponCollection + { + return $this->coupons; + } + + public function setCoupons(CouponCollection $coupons): void + { + $this->coupons = $coupons; + } + + public function getGeoCoordinates(): ?GeoCoordinates + { + return $this->geoCoordinates; + } + + public function setGeoCoordinates(?GeoCoordinates $geoCoordinates): void + { + $this->geoCoordinates = $geoCoordinates; + } + + public function jsonSerialize(): array + { + return \array_filter(parent::jsonSerialize()); + } +} diff --git a/src/AgentCommerce/Struct/V1/PaymentMethod.php b/src/AgentCommerce/Struct/V1/PaymentMethod.php new file mode 100644 index 000000000..90510a22d --- /dev/null +++ b/src/AgentCommerce/Struct/V1/PaymentMethod.php @@ -0,0 +1,108 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Struct; + +/** + * @experimental + * + * Payment method information for PayPal Cart API. This API is specifically designed for PayPal's shopping cart service, so only PayPal payment methods are supported. + * + * Payment Flow: + * + * Cart creation generates a payment token (in payment_method.token) + * Customer completes PayPal approval (Smart Wallet) + * PayPal provides token and payer_id for checkout + * Merchant receives PayPal payment confirmation + * + * Billing Address Behavior: + * + * PayPal handles all billing address collection internally for payment processing + * Merchants can optionally collect billing addresses for tax calculation and business purposes + * Billing address in cart is for merchant use cases, not payment requirements + * Billing addresses are typically available from customer profile data + * + * Note: Other payment methods (credit cards, Apple Pay, etc.) would be handled by separate merchant payment systems outside of this PayPal Cart API. + */ +#[Package('checkout')] +#[OA\Schema( + schema: 'paypal_agentic_commerce_v1_payment_method', + required: ['type'] +)] +class PaymentMethod extends Struct +{ + /** + * Payment method type - only PayPal is supported by this API + */ + #[OA\Property( + type: 'string', + enum: ['paypal'] + )] + protected string $type = 'paypal'; + + /** + * PayPal payment token from cart creation or customer approval + */ + #[OA\Property(type: 'string')] + protected ?string $token = null; + + /** + * PayPal payer identifier provided after customer approval + */ + #[OA\Property(type: 'string')] + protected ?string $payerId = null; + + /** + * URL used to inform merchant that the PayPal buyer approved the order + */ + #[OA\Property(type: 'string')] + protected ?string $approvalUrl = null; + + public function getType(): string + { + return $this->type; + } + + public function setType(string $type): void + { + $this->type = $type; + } + + public function getToken(): ?string + { + return $this->token; + } + + public function setToken(?string $token): void + { + $this->token = $token; + } + + public function getPayerId(): ?string + { + return $this->payerId; + } + + public function setPayerId(?string $payerId): void + { + $this->payerId = $payerId; + } + + public function getApprovalUrl(): ?string + { + return $this->approvalUrl; + } + + public function setApprovalUrl(?string $approvalUrl): void + { + $this->approvalUrl = $approvalUrl; + } +} diff --git a/src/AgentCommerce/Struct/V1/Phone.php b/src/AgentCommerce/Struct/V1/Phone.php new file mode 100644 index 000000000..35381b0f7 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Phone.php @@ -0,0 +1,124 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Struct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema( + schema: 'paypal_agentic_commerce_v1_phone', + required: ['countryCode', 'nationalNumber'] +)] +class Phone extends Struct +{ + private const PHONE_NUMBER_REGEX = '/\+(\d{1,3})\s(\d{1,14})(-?(\d{1,15}))?/'; + + /** + * The country calling code (CC), in its canonical international E.164 numbering plan format. + * The combined length of the CC and the national number must not be greater than 15 digits. + * The national number consists of a national destination code (NDC) and subscriber number (SN) + */ + #[OA\Property( + type: 'string', + maxLength: 3, + minLength: 1, + pattern: '^[0-9]{1,3}?$' + )] + protected string $countryCode; + + /** + * The national number, in its canonical international E.164 numbering plan format. + * The combined length of the country calling code (CC) and the national number must not be greater than 15 digits. + * The national number consists of a national destination code (NDC) and subscriber number (SN). + */ + #[OA\Property( + type: 'string', + maxLength: 14, + minLength: 1, + pattern: '^[0-9]{1,14}?$' + )] + protected string $nationalNumber; + + /** + * The extension number + */ + #[OA\Property( + type: 'string', + maxLength: 15, + minLength: 1, + pattern: '^[0-9]{1,15}?$' + )] + protected ?string $extensionNumber = null; + + public static function isValidPhoneNumber(string $phoneNumber): bool + { + return (bool) preg_match(self::PHONE_NUMBER_REGEX, $phoneNumber); + } + + public function getCountryCode(): string + { + return $this->countryCode; + } + + public function setCountryCode(string $countryCode): void + { + $this->countryCode = $countryCode; + } + + public function getNationalNumber(): string + { + return $this->nationalNumber; + } + + public function setNationalNumber(string $nationalNumber): void + { + $this->nationalNumber = $nationalNumber; + } + + public function getExtensionNumber(): ?string + { + return $this->extensionNumber; + } + + public function setExtensionNumber(?string $extensionNumber): void + { + $this->extensionNumber = $extensionNumber; + } + + public function jsonSerialize(): array + { + return \array_filter(parent::jsonSerialize()); + } + + public function getFullPhoneNumber(): string + { + $number = '+' . $this->countryCode . ' ' . $this->nationalNumber; + + if ($this->extensionNumber) { + $number .= '-' . $this->extensionNumber; + } + + return $number; + } + + public function setPhoneNumber(string $phoneNumber): void + { + if (!preg_match(self::PHONE_NUMBER_REGEX, $phoneNumber, $matches)) { + throw new \InvalidArgumentException('Invalid phone number: ' . $phoneNumber); + } + + $this->countryCode = $matches[1]; + $this->nationalNumber = $matches[2]; + $this->extensionNumber = $matches[4] ?? null; + } +} diff --git a/src/AgentCommerce/Struct/V1/Referral/BusinessHour.php b/src/AgentCommerce/Struct/V1/Referral/BusinessHour.php new file mode 100644 index 000000000..3200c8445 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Referral/BusinessHour.php @@ -0,0 +1,59 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1\Referral; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Struct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema(schema: 'paypal_agentic_commerce_v1_referral_business_hour')] +class BusinessHour extends Struct +{ + #[OA\Property(type: 'string')] + protected string $openTime; + + #[OA\Property(type: 'string')] + protected string $closeTime; + + #[OA\Property(type: 'string')] + protected string $timezone; + + public function getOpenTime(): string + { + return $this->openTime; + } + + public function setOpenTime(string $openTime): void + { + $this->openTime = $openTime; + } + + public function getCloseTime(): string + { + return $this->closeTime; + } + + public function setCloseTime(string $closeTime): void + { + $this->closeTime = $closeTime; + } + + public function getTimezone(): string + { + return $this->timezone; + } + + public function setTimezone(string $timezone): void + { + $this->timezone = $timezone; + } +} diff --git a/src/AgentCommerce/Struct/V1/Referral/BusinessHourCollection.php b/src/AgentCommerce/Struct/V1/Referral/BusinessHourCollection.php new file mode 100644 index 000000000..ed58b58d5 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Referral/BusinessHourCollection.php @@ -0,0 +1,25 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1\Referral; + +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Collection; + +/** + * @experimental + * + * @extends Collection + */ +#[Package('checkout')] +class BusinessHourCollection extends Collection +{ + protected function getExpectedClass(): string + { + return BusinessHour::class; + } +} diff --git a/src/AgentCommerce/Struct/V1/Referral/CustomOption.php b/src/AgentCommerce/Struct/V1/Referral/CustomOption.php new file mode 100644 index 000000000..272f4a250 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Referral/CustomOption.php @@ -0,0 +1,59 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1\Referral; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Struct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema(schema: 'paypal_agentic_commerce_v1_referral_custom_option')] +class CustomOption extends Struct +{ + #[OA\Property(type: 'string')] + protected string $name; + + #[OA\Property(type: 'string')] + protected string $value; + + #[OA\Property(type: 'string')] + protected string $priceModifier; + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public function getValue(): string + { + return $this->value; + } + + public function setValue(string $value): void + { + $this->value = $value; + } + + public function getPriceModifier(): string + { + return $this->priceModifier; + } + + public function setPriceModifier(string $priceModifier): void + { + $this->priceModifier = $priceModifier; + } +} diff --git a/src/AgentCommerce/Struct/V1/Referral/CustomOptionCollection.php b/src/AgentCommerce/Struct/V1/Referral/CustomOptionCollection.php new file mode 100644 index 000000000..07a474e84 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Referral/CustomOptionCollection.php @@ -0,0 +1,25 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1\Referral; + +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Collection; + +/** + * @experimental + * + * @extends Collection + */ +#[Package('checkout')] +class CustomOptionCollection extends Collection +{ + protected function getExpectedClass(): string + { + return CustomOption::class; + } +} diff --git a/src/AgentCommerce/Struct/V1/Referral/CustomerName.php b/src/AgentCommerce/Struct/V1/Referral/CustomerName.php new file mode 100644 index 000000000..e60bdd465 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Referral/CustomerName.php @@ -0,0 +1,46 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1\Referral; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Struct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema(schema: 'paypal_agentic_commerce_v1_referral_customer_name')] +class CustomerName extends Struct +{ + #[OA\Property(type: 'string')] + protected string $givenName; + + #[OA\Property(type: 'string')] + protected string $surname; + + public function getGivenName(): string + { + return $this->givenName; + } + + public function setGivenName(string $givenName): void + { + $this->givenName = $givenName; + } + + public function getSurname(): string + { + return $this->surname; + } + + public function setSurname(string $surname): void + { + $this->surname = $surname; + } +} diff --git a/src/AgentCommerce/Struct/V1/Referral/Measurements.php b/src/AgentCommerce/Struct/V1/Referral/Measurements.php new file mode 100644 index 000000000..127178222 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Referral/Measurements.php @@ -0,0 +1,72 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1\Referral; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Struct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema(schema: 'paypal_agentic_commerce_v1_referral_measurements')] +class Measurements extends Struct +{ + #[OA\Property(type: 'string')] + protected string $chest; + + #[OA\Property(type: 'string')] + protected string $waist; + + #[OA\Property(type: 'string')] + protected string $height; + + #[OA\Property(type: 'string')] + protected string $weight; + + public function getChest(): string + { + return $this->chest; + } + + public function setChest(string $chest): void + { + $this->chest = $chest; + } + + public function getWaist(): string + { + return $this->waist; + } + + public function setWaist(string $waist): void + { + $this->waist = $waist; + } + + public function getHeight(): string + { + return $this->height; + } + + public function setHeight(string $height): void + { + $this->height = $height; + } + + public function getWeight(): string + { + return $this->weight; + } + + public function setWeight(string $weight): void + { + $this->weight = $weight; + } +} diff --git a/src/AgentCommerce/Struct/V1/Referral/MetaData.php b/src/AgentCommerce/Struct/V1/Referral/MetaData.php new file mode 100644 index 000000000..dc84db592 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Referral/MetaData.php @@ -0,0 +1,106 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1\Referral; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Struct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema(schema: 'paypal_agentic_commerce_v1_referral_meta_data')] +class MetaData extends Struct +{ + public const PRIORITY__HIGH = 'HIGH'; + public const PRIORITY__MEDIUM = 'MEDIUM'; + public const PRIORITY__LOW = 'LOW'; + + #[OA\Property(type: 'string')] + protected string $costImpact; + + #[OA\Property(type: 'string')] + protected string $priority; + + #[OA\Property(type: 'string')] + protected string $waist; + + #[OA\Property(type: 'boolean')] + protected bool $autoApplicable; + + #[OA\Property(type: 'string')] + protected string $estimatedTime; + + #[OA\Property(type: 'boolean')] + protected bool $redirectRequired; + + public function getCostImpact(): string + { + return $this->costImpact; + } + + public function setCostImpact(string $costImpact): void + { + $this->costImpact = $costImpact; + } + + public function getPriority(): string + { + return $this->priority; + } + + public function setPriority(string $priority): void + { + if (!\in_array($priority, [self::PRIORITY__HIGH, self::PRIORITY__MEDIUM, self::PRIORITY__LOW], true)) { + throw new \InvalidArgumentException('Invalid priority'); + } + + $this->priority = $priority; + } + + public function getWaist(): string + { + return $this->waist; + } + + public function setWaist(string $waist): void + { + $this->waist = $waist; + } + + public function isAutoApplicable(): bool + { + return $this->autoApplicable; + } + + public function setAutoApplicable(bool $autoApplicable): void + { + $this->autoApplicable = $autoApplicable; + } + + public function getEstimatedTime(): string + { + return $this->estimatedTime; + } + + public function setEstimatedTime(string $estimatedTime): void + { + $this->estimatedTime = $estimatedTime; + } + + public function isRedirectRequired(): bool + { + return $this->redirectRequired; + } + + public function setRedirectRequired(bool $redirectRequired): void + { + $this->redirectRequired = $redirectRequired; + } +} diff --git a/src/AgentCommerce/Struct/V1/Referral/MetaDataCollection.php b/src/AgentCommerce/Struct/V1/Referral/MetaDataCollection.php new file mode 100644 index 000000000..76ece4006 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Referral/MetaDataCollection.php @@ -0,0 +1,25 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1\Referral; + +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Collection; + +/** + * @experimental + * + * @extends Collection + */ +#[Package('checkout')] +class MetaDataCollection extends Collection +{ + protected function getExpectedClass(): string + { + return MetaData::class; + } +} diff --git a/src/AgentCommerce/Struct/V1/Referral/MixedItem.php b/src/AgentCommerce/Struct/V1/Referral/MixedItem.php new file mode 100644 index 000000000..1e83dc379 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Referral/MixedItem.php @@ -0,0 +1,46 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1\Referral; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Struct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema(schema: 'paypal_agentic_commerce_v1_referral_mixed_item')] +class MixedItem extends Struct +{ + #[OA\Property(type: 'string')] + protected string $itemId; + + #[OA\Property(type: 'string')] + protected string $currency; + + public function getItemId(): string + { + return $this->itemId; + } + + public function setItemId(string $itemId): void + { + $this->itemId = $itemId; + } + + public function getCurrency(): string + { + return $this->currency; + } + + public function setCurrency(string $currency): void + { + $this->currency = $currency; + } +} diff --git a/src/AgentCommerce/Struct/V1/Referral/MixedItemCollection.php b/src/AgentCommerce/Struct/V1/Referral/MixedItemCollection.php new file mode 100644 index 000000000..123ea0fa0 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Referral/MixedItemCollection.php @@ -0,0 +1,25 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1\Referral; + +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Collection; + +/** + * @experimental + * + * @extends Collection + */ +#[Package('checkout')] +class MixedItemCollection extends Collection +{ + protected function getExpectedClass(): string + { + return MixedItem::class; + } +} diff --git a/src/AgentCommerce/Struct/V1/Referral/Recipient.php b/src/AgentCommerce/Struct/V1/Referral/Recipient.php new file mode 100644 index 000000000..be9778c2b --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Referral/Recipient.php @@ -0,0 +1,59 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1\Referral; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Struct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema(schema: 'paypal_agentic_commerce_v1_referral_recipient')] +class Recipient extends Struct +{ + #[OA\Property(type: 'string')] + protected string $name; + + #[OA\Property(type: 'string')] + protected string $email; + + #[OA\Property(type: 'string')] + protected string $phone; + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public function getEmail(): string + { + return $this->email; + } + + public function setEmail(string $email): void + { + $this->email = $email; + } + + public function getPhone(): string + { + return $this->phone; + } + + public function setPhone(string $phone): void + { + $this->phone = $phone; + } +} diff --git a/src/AgentCommerce/Struct/V1/Referral/SelectedAttribute.php b/src/AgentCommerce/Struct/V1/Referral/SelectedAttribute.php new file mode 100644 index 000000000..fa55ea06d --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Referral/SelectedAttribute.php @@ -0,0 +1,46 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1\Referral; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Struct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema(schema: 'paypal_agentic_commerce_v1_referral_selected_attribute')] +class SelectedAttribute extends Struct +{ + #[OA\Property(type: 'string')] + protected string $name; + + #[OA\Property(type: 'string')] + protected string $value; + + public function getValue(): string + { + return $this->value; + } + + public function setValue(string $value): void + { + $this->value = $value; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): void + { + $this->name = $name; + } +} diff --git a/src/AgentCommerce/Struct/V1/Referral/SelectedAttributeCollection.php b/src/AgentCommerce/Struct/V1/Referral/SelectedAttributeCollection.php new file mode 100644 index 000000000..55417c403 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Referral/SelectedAttributeCollection.php @@ -0,0 +1,25 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1\Referral; + +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Collection; + +/** + * @experimental + * + * @extends Collection + */ +#[Package('checkout')] +class SelectedAttributeCollection extends Collection +{ + protected function getExpectedClass(): string + { + return SelectedAttribute::class; + } +} diff --git a/src/AgentCommerce/Struct/V1/Referral/SuggestedCorrection.php b/src/AgentCommerce/Struct/V1/Referral/SuggestedCorrection.php new file mode 100644 index 000000000..9623e98cd --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Referral/SuggestedCorrection.php @@ -0,0 +1,59 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1\Referral; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Struct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema(schema: 'paypal_agentic_commerce_v1_referral_suggested_correction')] +class SuggestedCorrection extends Struct +{ + #[OA\Property(type: 'string')] + protected string $postalCode; + + #[OA\Property(type: 'string')] + protected string $addressLine1; + + #[OA\Property(type: 'string')] + protected string $adminArea2; + + public function getPostalCode(): string + { + return $this->postalCode; + } + + public function setPostalCode(string $postalCode): void + { + $this->postalCode = $postalCode; + } + + public function getAddressLine1(): string + { + return $this->addressLine1; + } + + public function setAddressLine1(string $addressLine1): void + { + $this->addressLine1 = $addressLine1; + } + + public function getAdminArea2(): string + { + return $this->adminArea2; + } + + public function setAdminArea2(string $adminArea2): void + { + $this->adminArea2 = $adminArea2; + } +} diff --git a/src/AgentCommerce/Struct/V1/Referral/SuggestedCorrectionCollection.php b/src/AgentCommerce/Struct/V1/Referral/SuggestedCorrectionCollection.php new file mode 100644 index 000000000..d3cebc1da --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Referral/SuggestedCorrectionCollection.php @@ -0,0 +1,25 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1\Referral; + +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Collection; + +/** + * @experimental + * + * @extends Collection + */ +#[Package('checkout')] +class SuggestedCorrectionCollection extends Collection +{ + protected function getExpectedClass(): string + { + return SuggestedCorrection::class; + } +} diff --git a/src/AgentCommerce/Struct/V1/ResolutionOption.php b/src/AgentCommerce/Struct/V1/ResolutionOption.php new file mode 100644 index 000000000..270192a7a --- /dev/null +++ b/src/AgentCommerce/Struct/V1/ResolutionOption.php @@ -0,0 +1,153 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Struct; +use Swag\PayPal\AgentCommerce\Struct\V1\Referral\MetaData; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema( + schema: 'paypal_agentic_commerce_v1_resolution_option', + required: ['action', 'label'] +)] +class ResolutionOption extends Struct +{ + public const ACTION__REDIRECT_TO_MERCHANT = 'REDIRECT_TO_MERCHANT'; + public const ACTION__MODIFY_CART = 'MODIFY_CART'; + public const ACTION__ACCEPT_NEW_PRICE = 'ACCEPT_NEW_PRICE'; + public const ACTION__ACCEPT_BACK_ORDER = 'ACCEPT_BACK_ORDER'; + public const ACTION__SUGGEST_ALTERNATIVE = 'SUGGEST_ALTERNATIVE'; + public const ACTION__REMOVE_ITEM = 'REMOVE_ITEM'; + public const ACTION__UPDATE_ADDRESS = 'UPDATE_ADDRESS'; + public const ACTION__PROVIDE_MISSING_FIELD = 'PROVIDE_MISSING_FIELD'; + public const ACTION__USE_DIFFERENT_PAYMENT = 'USE_DIFFERENT_PAYMENT'; + public const ACTION__SPLIT_ORDER = 'SPLIT_ORDER'; + public const ACTION__CONTACT_SUPPORT = 'CONTACT_SUPPORT'; + public const ACTION__RETRY_LATER = 'RETRY_LATER'; + public const ACTION__REQUEST_APPROVAL = 'REQUEST_APPROVAL'; + public const ACTION__WAIT_FOR_RESTOCK = 'WAIT_FOR_RESTOCK'; + public const ACTION__USE_DIFFERENT_CURRENCY = 'USE_DIFFERENT_CURRENCY'; + public const ACTION__ACCEPT_PRE_ORDER = 'ACCEPT_PRE_ORDER'; + public const ACTION__UPDATE_SHIPPING_METHOD = 'UPDATE_SHIPPING_METHOD'; + public const ACTION__ACCEPT_TERMS = 'ACCEPT_TERMS'; + public const ACTION__VERIFY_ACCOUNT = 'VERIFY_ACCOUNT'; + public const ACTION__APPLY_DIFFERENT_COUPON = 'APPLY_DIFFERENT_COUPON'; + public const ACTION__REMOVE_COUPON = 'REMOVE_COUPON'; + public const ACTION__CHOOSE_DIFFERENT_VARIANT = 'CHOOSE_DIFFERENT_VARIANT'; + + public const ACTIONS = [ + self::ACTION__REDIRECT_TO_MERCHANT, + self::ACTION__MODIFY_CART, + self::ACTION__ACCEPT_NEW_PRICE, + self::ACTION__ACCEPT_BACK_ORDER, + self::ACTION__SUGGEST_ALTERNATIVE, + self::ACTION__REMOVE_ITEM, + self::ACTION__UPDATE_ADDRESS, + self::ACTION__PROVIDE_MISSING_FIELD, + self::ACTION__USE_DIFFERENT_PAYMENT, + self::ACTION__SPLIT_ORDER, + self::ACTION__CONTACT_SUPPORT, + self::ACTION__RETRY_LATER, + self::ACTION__REQUEST_APPROVAL, + self::ACTION__WAIT_FOR_RESTOCK, + self::ACTION__USE_DIFFERENT_CURRENCY, + self::ACTION__ACCEPT_PRE_ORDER, + self::ACTION__UPDATE_SHIPPING_METHOD, + self::ACTION__ACCEPT_TERMS, + self::ACTION__VERIFY_ACCOUNT, + self::ACTION__APPLY_DIFFERENT_COUPON, + self::ACTION__REMOVE_COUPON, + self::ACTION__CHOOSE_DIFFERENT_VARIANT, + ]; + + /** + * Machine-readable action identifier + */ + #[OA\Property( + type: 'string', + enum: self::ACTIONS, + )] + protected string $action; + + /** + * Human-readable action label + */ + #[OA\Property(type: 'string')] + protected string $label; + + /** + * URL to redirect to for resolution + */ + #[OA\Property(type: 'string')] + protected ?string $url = null; + + /** + * Additional action metadata + */ + #[OA\Property(ref: MetaData::class)] + protected MetaData $metadata; + + public function getAction(): string + { + return $this->action; + } + + public function setAction(string $action): void + { + if (!\in_array($action, self::ACTIONS, true)) { + throw new \InvalidArgumentException(\sprintf('Action "%s" is not valid.', $action)); + } + + $this->action = $action; + } + + public function getLabel(): string + { + return $this->label; + } + + public function setLabel(string $label): void + { + $this->label = $label; + } + + public function getUrl(): ?string + { + return $this->url; + } + + public function setUrl(?string $url): void + { + $this->url = $url; + } + + public function getMetadata(): MetaData + { + return $this->metadata; + } + + public function setMetadata(MetaData $metadata): void + { + $this->metadata = $metadata; + } + + public function jsonSerialize(): array + { + return \array_filter(parent::jsonSerialize()); + } + + public function isset(string $propertyName): bool + { + return isset($this->{$propertyName}); + } +} diff --git a/src/AgentCommerce/Struct/V1/ResolutionOptionCollection.php b/src/AgentCommerce/Struct/V1/ResolutionOptionCollection.php new file mode 100644 index 000000000..ea7fe817f --- /dev/null +++ b/src/AgentCommerce/Struct/V1/ResolutionOptionCollection.php @@ -0,0 +1,25 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1; + +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Collection; + +/** + * @experimental + * + * @extends Collection + */ +#[Package('checkout')] +class ResolutionOptionCollection extends Collection +{ + protected function getExpectedClass(): string + { + return ResolutionOption::class; + } +} diff --git a/src/AgentCommerce/Struct/V1/ShippingAddress.php b/src/AgentCommerce/Struct/V1/ShippingAddress.php new file mode 100644 index 000000000..ea870d9fb --- /dev/null +++ b/src/AgentCommerce/Struct/V1/ShippingAddress.php @@ -0,0 +1,18 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1; + +use Shopware\Core\Framework\Log\Package; + +/** + * @experimental + */ +#[Package('checkout')] +class ShippingAddress extends Address +{ +} diff --git a/src/AgentCommerce/Struct/V1/ShippingOption.php b/src/AgentCommerce/Struct/V1/ShippingOption.php new file mode 100644 index 000000000..23cc2ecd1 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/ShippingOption.php @@ -0,0 +1,124 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Struct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema( + schema: 'paypal_agentic_commerce_v1_shipping_option', + required: ['price', 'isSelected'] +)] +class ShippingOption extends Struct +{ + /** + * Unique shipping option identifier + */ + #[OA\Property(type: 'string')] + protected ?string $id = null; + + /** + * Display name + */ + #[OA\Property(type: 'string')] + protected ?string $name = null; + + /** + * Detailed description + */ + #[OA\Property(type: 'string')] + protected ?string $description = null; + + #[OA\Property(ref: Money::class)] + protected Money $price; + + /** + * Whether this shipping option is currently selected + */ + #[OA\Property(type: 'boolean')] + protected bool $isSelected; + + /** + * Estimated delivery date in YYYY-MM-DD format + */ + #[OA\Property( + type: 'string', + pattern: '^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])$' + )] + protected ?string $estimatedDelivery = null; + + public function getId(): ?string + { + return $this->id; + } + + public function setId(?string $id): void + { + $this->id = $id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(?string $name): void + { + $this->name = $name; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(?string $description): void + { + $this->description = $description; + } + + public function getPrice(): Money + { + return $this->price; + } + + public function setPrice(Money $price): void + { + $this->price = $price; + } + + public function isSelected(): bool + { + return $this->isSelected; + } + + public function setIsSelected(bool $isSelected): void + { + $this->isSelected = $isSelected; + } + + public function getEstimatedDelivery(): ?string + { + return $this->estimatedDelivery; + } + + public function setEstimatedDelivery(?string $estimatedDelivery): void + { + $this->estimatedDelivery = $estimatedDelivery; + } + + public function jsonSerialize(): array + { + return \array_filter(parent::jsonSerialize()); + } +} diff --git a/src/AgentCommerce/Struct/V1/ShippingOptionCollection.php b/src/AgentCommerce/Struct/V1/ShippingOptionCollection.php new file mode 100644 index 000000000..cd0fbcedd --- /dev/null +++ b/src/AgentCommerce/Struct/V1/ShippingOptionCollection.php @@ -0,0 +1,25 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1; + +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Collection; + +/** + * @experimental + * + * @extends Collection + */ +#[Package('checkout')] +class ShippingOptionCollection extends Collection +{ + protected function getExpectedClass(): string + { + return ShippingOption::class; + } +} diff --git a/src/AgentCommerce/Struct/V1/ValidationIssue.php b/src/AgentCommerce/Struct/V1/ValidationIssue.php new file mode 100644 index 000000000..ad9da2e7d --- /dev/null +++ b/src/AgentCommerce/Struct/V1/ValidationIssue.php @@ -0,0 +1,211 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Struct; +use Swag\PayPal\AgentCommerce\Struct\V1\Context\AbstractContext; +use Swag\PayPal\AgentCommerce\Struct\V1\Context\BusinessRuleErrorContext; +use Swag\PayPal\AgentCommerce\Struct\V1\Context\DataErrorContext; +use Swag\PayPal\AgentCommerce\Struct\V1\Context\InventoryIssueContext; +use Swag\PayPal\AgentCommerce\Struct\V1\Context\PaymentErrorContext; +use Swag\PayPal\AgentCommerce\Struct\V1\Context\PricingErrorContext; +use Swag\PayPal\AgentCommerce\Struct\V1\Context\ShippingErrorContext; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema( + schema: 'paypal_agentic_commerce_v1_validation_issue', + required: ['code', 'type', 'message'] +)] +class ValidationIssue extends Struct +{ + public const CODE__INVENTORY_ISSUE = 'INVENTORY_ISSUE'; + public const CODE__PRICING_ERROR = 'PRICING_ERROR'; + public const CODE__SHIPPING_ERROR = 'SHIPPING_ERROR'; + public const CODE__PAYMENT_ERROR = 'PAYMENT_ERROR'; + public const CODE__DATA_ERROR = 'DATA_ERROR'; + public const CODE__BUSINESS_RULE_ERROR = 'BUSINESS_RULE_ERROR'; + + public const TYPE__MISSING_FIELD = 'MISSING_FIELD'; + public const TYPE__INVALID_DATA = 'INVALID_DATA'; + public const TYPE__BUSINESS_RULE = 'BUSINESS_RULE'; + + private const CODES = [ + self::CODE__INVENTORY_ISSUE, + self::CODE__PRICING_ERROR, + self::CODE__SHIPPING_ERROR, + self::CODE__PAYMENT_ERROR, + self::CODE__DATA_ERROR, + self::CODE__BUSINESS_RULE_ERROR, + ]; + + private const TYPES = [self::TYPE__MISSING_FIELD, self::TYPE__INVALID_DATA, self::TYPE__BUSINESS_RULE]; + + /** + * Consolidated error category + */ + #[OA\Property( + type: 'string', + enum: self::CODES + )] + protected string $code; + + /** + * Type classification for error handling + */ + #[OA\Property( + type: 'string', + enum: self::TYPES + )] + protected string $type; + + /** + * Technical message for developers and logging + */ + #[OA\Property(type: 'string')] + protected string $message; + + /** + * Customer-friendly message for end users + */ + #[OA\Property(type: 'string')] + protected ?string $userMessage = null; + + /** + * Specific item ID if the issue is item-specific + */ + #[OA\Property(type: 'string')] + protected ?string $itemId = null; + + /** + * Specific field name if the issue is field-specific + */ + #[OA\Property(type: 'string')] + protected ?string $field = null; + + /** + * Category-specific context information + */ + #[OA\Property(oneOf: [ + new OA\Schema(ref: InventoryIssueContext::class), + new OA\Schema(ref: PricingErrorContext::class), + new OA\Schema(ref: ShippingErrorContext::class), + new OA\Schema(ref: PaymentErrorContext::class), + new OA\Schema(ref: DataErrorContext::class), + new OA\Schema(ref: BusinessRuleErrorContext::class), + ])] + protected ?AbstractContext $context = null; + + /** + * Available actions to resolve this issue + */ + #[OA\Property(ref: ResolutionOptionCollection::class)] + protected ResolutionOptionCollection $resolutionOptions; + + public function getCode(): string + { + return $this->code; + } + + public function setCode(string $code): void + { + if (!\in_array($code, self::CODES, true)) { + throw new \InvalidArgumentException('Invalid code'); + } + + $this->code = $code; + } + + public function getType(): string + { + return $this->type; + } + + public function setType(string $type): void + { + if (!\in_array($type, self::TYPES, true)) { + throw new \InvalidArgumentException('Invalid type'); + } + + $this->type = $type; + } + + public function getMessage(): string + { + return $this->message; + } + + public function setMessage(string $message): void + { + $this->message = $message; + } + + public function getUserMessage(): ?string + { + return $this->userMessage; + } + + public function setUserMessage(?string $userMessage): void + { + $this->userMessage = $userMessage; + } + + public function getItemId(): ?string + { + return $this->itemId; + } + + public function setItemId(?string $itemId): void + { + $this->itemId = $itemId; + } + + public function getField(): ?string + { + return $this->field; + } + + public function setField(?string $field): void + { + $this->field = $field; + } + + public function getContext(): ?AbstractContext + { + return $this->context; + } + + public function setContext(?AbstractContext $context): void + { + $this->context = $context; + } + + public function getResolutionOptions(): ResolutionOptionCollection + { + return $this->resolutionOptions; + } + + public function setResolutionOptions(ResolutionOptionCollection $resolutionOptions): void + { + $this->resolutionOptions = $resolutionOptions; + } + + public function jsonSerialize(): array + { + return \array_filter(parent::jsonSerialize()); + } + + public function isset(string $propertyName): bool + { + return isset($this->{$propertyName}); + } +} diff --git a/src/AgentCommerce/Struct/V1/ValidationIssueCollection.php b/src/AgentCommerce/Struct/V1/ValidationIssueCollection.php new file mode 100644 index 000000000..ee2d3878e --- /dev/null +++ b/src/AgentCommerce/Struct/V1/ValidationIssueCollection.php @@ -0,0 +1,25 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1; + +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Collection; + +/** + * @experimental + * + * @extends Collection + */ +#[Package('checkout')] +class ValidationIssueCollection extends Collection +{ + protected function getExpectedClass(): string + { + return ValidationIssue::class; + } +} diff --git a/src/AgentCommerce/Struct/V1/Value/AgeVerificationValue.php b/src/AgentCommerce/Struct/V1/Value/AgeVerificationValue.php new file mode 100644 index 000000000..59c58d39d --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Value/AgeVerificationValue.php @@ -0,0 +1,81 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1\Value; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema( + schema: 'paypal_agentic_commerce_v1_value_age_verification_value', + required: ['confirmed'] +)] +class AgeVerificationValue +{ + public const METHOD__SELF_DECLARATION = 'self_declaration'; + public const METHOD__ID_VERIFICATION = 'id_verification'; + public const METHOD__THIRD_PARTY = 'third_party'; + + /** + * Whether age verification was confirmed + */ + #[OA\Property(type: 'boolean')] + protected bool $confirmed = false; + + /** + * Method used for age verification + */ + #[OA\Property( + type: 'string', + enum: [self::METHOD__SELF_DECLARATION, self::METHOD__ID_VERIFICATION, self::METHOD__THIRD_PARTY], + )] + protected ?string $verificationMethod = null; + + /** + * When verification was completed + */ + #[OA\Property(type: 'string')] + protected ?string $verificationDate = null; + + public function isConfirmed(): bool + { + return $this->confirmed; + } + + public function setConfirmed(bool $confirmed): void + { + $this->confirmed = $confirmed; + } + + public function getVerificationMethod(): ?string + { + return $this->verificationMethod; + } + + public function setVerificationMethod(?string $verificationMethod): void + { + if (!\in_array($verificationMethod, [self::METHOD__SELF_DECLARATION, self::METHOD__ID_VERIFICATION, self::METHOD__THIRD_PARTY], true)) { + throw new \InvalidArgumentException(\sprintf('Verification Method "%s" is not valid.', $verificationMethod)); + } + + $this->verificationMethod = $verificationMethod; + } + + public function getVerificationDate(): ?string + { + return $this->verificationDate; + } + + public function setVerificationDate(?string $verificationDate): void + { + $this->verificationDate = $verificationDate; + } +} diff --git a/src/AgentCommerce/Struct/V1/Value/AllergyInformationValue.php b/src/AgentCommerce/Struct/V1/Value/AllergyInformationValue.php new file mode 100644 index 000000000..42663953f --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Value/AllergyInformationValue.php @@ -0,0 +1,130 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1\Value; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Struct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema(schema: 'paypal_agentic_commerce_v1_value_allergy_information_value')] +class AllergyInformationValue extends Struct +{ + public const SEVERITY__MILD = 'mild'; + public const SEVERITY__MODERATE = 'moderate'; + public const SEVERITY__SEVERE = 'severe'; + public const SEVERITY__LIFE_THREATENING = 'life_threatening'; + + /** + * List of known allergies + * + * @var string[] + */ + #[OA\Property( + type: 'array', + items: new OA\Items(type: 'string'), + )] + protected ?array $allergies = null; + + /** + * Allergy severity level + */ + #[OA\Property( + type: 'string', + enum: [self::SEVERITY__LIFE_THREATENING, self::SEVERITY__MILD, self::SEVERITY__MODERATE, self::SEVERITY__SEVERE], + )] + protected ?string $severity = null; + + /** + * Medications to avoid + * + * @var string[] + */ + #[OA\Property( + type: 'array', + items: new OA\Items(type: 'string'), + )] + protected ?array $medications = null; + + /** + * Emergency contact information + * + * example: +1-555-999-8888 + */ + #[OA\Property(type: 'string')] + protected ?string $emergencyContact = null; + + /** + * @return ?string[] + */ + public function getAllergies(): ?array + { + return $this->allergies; + } + + /** + * @param ?string[] $allergies + */ + public function setAllergies(?array $allergies): void + { + $this->allergies = $allergies; + } + + public function addAllergy(string $allergy): void + { + $this->allergies[] = $allergy; + } + + public function getSeverity(): ?string + { + return $this->severity; + } + + public function setSeverity(?string $severity): void + { + if (!\in_array($severity, [self::SEVERITY__LIFE_THREATENING, self::SEVERITY__MILD, self::SEVERITY__MODERATE, self::SEVERITY__SEVERE], true)) { + throw new \RuntimeException(\sprintf('Invalid value for severity: %s', $severity)); + } + + $this->severity = $severity; + } + + /** + * @return ?string[] + */ + public function getMedications(): ?array + { + return $this->medications; + } + + /** + * @param ?string[] $medications + */ + public function setMedications(?array $medications): void + { + $this->medications = $medications; + } + + public function addMedication(string $medication): void + { + $this->medications[] = $medication; + } + + public function getEmergencyContact(): ?string + { + return $this->emergencyContact; + } + + public function setEmergencyContact(?string $emergencyContact): void + { + $this->emergencyContact = $emergencyContact; + } +} diff --git a/src/AgentCommerce/Struct/V1/Value/CustomEngravingTextValue.php b/src/AgentCommerce/Struct/V1/Value/CustomEngravingTextValue.php new file mode 100644 index 000000000..44516ac68 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Value/CustomEngravingTextValue.php @@ -0,0 +1,125 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1\Value; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Struct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema( + schema: 'paypal_agentic_commerce_v1_value_custom_engraving_text_value', + required: ['text'] +)] +class CustomEngravingTextValue extends Struct +{ + public const FONT__ARIAL = 'arial'; + public const FONT__TIMES = 'times'; + public const FONT__SCRIPT = 'script'; + public const FONT__BLOCK = 'block'; + + public const SIZE__SMALL = 'small'; + public const SIZE__MEDIUM = 'medium'; + public const SIZE__LARGE = 'large'; + + public const POSITION__FRONT = 'front'; + public const POSITION__BACK = 'back'; + public const POSITION__SIDE = 'side'; + public const POSITION__BOTTOM = 'bottom'; + + /** + * Text to be engraved + */ + #[OA\Property( + type: 'string', + maxLength: 100, + )] + protected string $text; + + /** + * Preferred font style + */ + #[OA\Property( + type: 'string', + enum: [self::FONT__ARIAL, self::FONT__TIMES, self::FONT__SCRIPT, self::FONT__BLOCK], + )] + protected ?string $font = null; + + /** + * Text size preference + */ + #[OA\Property( + type: 'string', + enum: [self::SIZE__SMALL, self::SIZE__MEDIUM, self::SIZE__LARGE] + )] + protected ?string $size = null; + + /** + * Engraving position + */ + #[OA\Property( + type: 'string', + enum: [self::POSITION__FRONT, self::POSITION__BACK, self::POSITION__SIDE, self::POSITION__BOTTOM] + )] + protected ?string $position = null; + + public function getText(): string + { + return $this->text; + } + + public function setText(string $text): void + { + $this->text = $text; + } + + public function getFont(): ?string + { + return $this->font; + } + + public function setFont(?string $font): void + { + if (!\in_array($font, [self::FONT__ARIAL, self::FONT__TIMES, self::FONT__SCRIPT, self::FONT__BLOCK], true)) { + throw new \InvalidArgumentException('Font must be one of "arial", "times", "script", "block"'); + } + + $this->font = $font; + } + + public function getSize(): ?string + { + return $this->size; + } + + public function setSize(?string $size): void + { + if (!\in_array($size, [self::SIZE__SMALL, self::SIZE__MEDIUM, self::SIZE__LARGE], true)) { + throw new \InvalidArgumentException('Size must be one of "small", "medium", "large"'); + } + + $this->size = $size; + } + + public function getPosition(): ?string + { + return $this->position; + } + + public function setPosition(?string $position): void + { + if (!\in_array($position, [self::POSITION__FRONT, self::POSITION__BACK, self::POSITION__SIDE, self::POSITION__BOTTOM], true)) { + throw new \InvalidArgumentException('Position must be one of "front", "back", "side", "bottom"'); + } + + $this->position = $position; + } +} diff --git a/src/AgentCommerce/Struct/V1/Value/CustomSizingInfoValue.php b/src/AgentCommerce/Struct/V1/Value/CustomSizingInfoValue.php new file mode 100644 index 000000000..21de99b04 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Value/CustomSizingInfoValue.php @@ -0,0 +1,76 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1\Value; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Struct; +use Swag\PayPal\AgentCommerce\Struct\V1\Referral\Measurements; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema(schema: 'paypal_agentic_commerce_v1_value_custom_sizing_info_value')] +class CustomSizingInfoValue extends Struct +{ + public const SIZE__TIGHT = 'tight'; + public const SIZE__REGULAR = 'regular'; + public const SIZE__LOOSE = 'loose'; + + /** + * Body measurements + */ + #[OA\Property(ref: Measurements::class)] + protected Measurements $measurements; + + /** + * Fit preference + */ + #[OA\Property( + type: 'string', + enum: [self::SIZE__TIGHT, self::SIZE__REGULAR, self::SIZE__LOOSE], + )] + protected ?string $sizePreference = null; + + /** + * Special sizing requirements + */ + #[OA\Property(type: 'string')] + protected ?string $specialRequirements = null; + + public function getMeasurements(): Measurements + { + return $this->measurements; + } + + public function setMeasurements(Measurements $measurements): void + { + $this->measurements = $measurements; + } + + public function getSizePreference(): ?string + { + return $this->sizePreference; + } + + public function setSizePreference(?string $sizePreference): void + { + $this->sizePreference = $sizePreference; + } + + public function getSpecialRequirements(): ?string + { + return $this->specialRequirements; + } + + public function setSpecialRequirements(?string $specialRequirements): void + { + $this->specialRequirements = $specialRequirements; + } +} diff --git a/src/AgentCommerce/Struct/V1/Value/DeliveryDatePreferenceValue.php b/src/AgentCommerce/Struct/V1/Value/DeliveryDatePreferenceValue.php new file mode 100644 index 000000000..03a1c88c8 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Value/DeliveryDatePreferenceValue.php @@ -0,0 +1,83 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1\Value; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Struct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema(schema: 'paypal_agentic_commerce_v1_value_delivery_date_preference_value')] +class DeliveryDatePreferenceValue extends Struct +{ + public const TIME_WINDOW__MORNING = 'morning'; + public const TIME_WINDOW__AFTERNOON = 'afternoon'; + public const TIME_WINDOW__EVENING = 'evening'; + public const TIME_WINDOW__ANYTIME = 'anytime'; + + /** + * Preferred delivery date + */ + #[OA\Property(type: 'string')] + protected ?string $preferredDate = null; + + /** + * Preferred time window + */ + #[OA\Property( + type: 'string', + enum: [self::TIME_WINDOW__MORNING, self::TIME_WINDOW__AFTERNOON, self::TIME_WINDOW__EVENING, self::TIME_WINDOW__ANYTIME], + )] + protected ?string $timeWindow = null; + + /** + * Specific preferred time (HH:MM format) + */ + #[OA\Property( + type: 'string', + pattern: '^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$' + )] + protected ?string $specificTime = null; + + public function getPreferredDate(): ?string + { + return $this->preferredDate; + } + + public function setPreferredDate(?string $preferredDate): void + { + if (!\in_array($preferredDate, [self::TIME_WINDOW__MORNING, self::TIME_WINDOW__AFTERNOON, self::TIME_WINDOW__EVENING, self::TIME_WINDOW__ANYTIME], true)) { + throw new \InvalidArgumentException('PreferredDate must be a valid date'); + } + + $this->preferredDate = $preferredDate; + } + + public function getTimeWindow(): ?string + { + return $this->timeWindow; + } + + public function setTimeWindow(?string $timeWindow): void + { + $this->timeWindow = $timeWindow; + } + + public function getSpecificTime(): ?string + { + return $this->specificTime; + } + + public function setSpecificTime(?string $specificTime): void + { + $this->specificTime = $specificTime; + } +} diff --git a/src/AgentCommerce/Struct/V1/Value/DeliveryInstructionsValue.php b/src/AgentCommerce/Struct/V1/Value/DeliveryInstructionsValue.php new file mode 100644 index 000000000..d9d4a9106 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Value/DeliveryInstructionsValue.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\AgentCommerce\Struct\V1\Value; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Struct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema( + schema: 'paypal_agentic_commerce_v1_value_delivery_instructions_value', + required: ['instructions'] +)] +class DeliveryInstructionsValue extends Struct +{ + /** + * Special delivery instructions + */ + #[OA\Property( + type: 'string', + maxLength: 200, + )] + protected string $instructions; + + /** + * Building or gate access code + */ + #[OA\Property(type: 'string')] + protected ?string $accessCode = null; + + /** + * Contact phone for delivery + */ + #[OA\Property(type: 'string')] + protected ?string $contactPhone = null; + + public function getInstructions(): string + { + return $this->instructions; + } + + public function setInstructions(string $instructions): void + { + $this->instructions = $instructions; + } + + public function getAccessCode(): ?string + { + return $this->accessCode; + } + + public function setAccessCode(?string $accessCode): void + { + $this->accessCode = $accessCode; + } + + public function getContactPhone(): ?string + { + return $this->contactPhone; + } + + public function setContactPhone(?string $contactPhone): void + { + $this->contactPhone = $contactPhone; + } +} diff --git a/src/AgentCommerce/Struct/V1/Value/GiftMessageValue.php b/src/AgentCommerce/Struct/V1/Value/GiftMessageValue.php new file mode 100644 index 000000000..aee83992d --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Value/GiftMessageValue.php @@ -0,0 +1,58 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1\Value; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Struct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema( + schema: 'paypal_agentic_commerce_v1_value_gift_message_value', + required: ['message'] +)] +class GiftMessageValue extends Struct +{ + /** + * Personal message for the recipient + */ + #[OA\Property( + type: 'string', + maxLength: 500, + )] + protected string $message; + + /** + * Name of the person sending the gift + */ + #[OA\Property(type: 'string')] + protected ?string $senderName = null; + + public function getMessage(): string + { + return $this->message; + } + + public function setMessage(string $message): void + { + $this->message = $message; + } + + public function getSenderName(): ?string + { + return $this->senderName; + } + + public function setSenderName(?string $senderName): void + { + $this->senderName = $senderName; + } +} diff --git a/src/AgentCommerce/Struct/V1/Value/GiftRecipientEmailValue.php b/src/AgentCommerce/Struct/V1/Value/GiftRecipientEmailValue.php new file mode 100644 index 000000000..19df08c1a --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Value/GiftRecipientEmailValue.php @@ -0,0 +1,55 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1\Value; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Struct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema( + schema: 'paypal_agentic_commerce_v1_value_gift_recipient_email_value', + required: ['email'] +)] +class GiftRecipientEmailValue extends Struct +{ + /** + * Recipient's email address + */ + #[OA\Property(type: 'string')] + protected string $email; + + /** + * Whether email was verified + */ + #[OA\Property(type: 'boolean')] + protected bool $verified = false; + + public function getEmail(): string + { + return $this->email; + } + + public function setEmail(string $email): void + { + $this->email = $email; + } + + public function isVerified(): bool + { + return $this->verified; + } + + public function setVerified(bool $verified): void + { + $this->verified = $verified; + } +} diff --git a/src/AgentCommerce/Struct/V1/Value/GiftRecipientNameValue.php b/src/AgentCommerce/Struct/V1/Value/GiftRecipientNameValue.php new file mode 100644 index 000000000..ac8051ff9 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Value/GiftRecipientNameValue.php @@ -0,0 +1,71 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1\Value; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Struct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema( + schema: 'paypal_agentic_commerce_v1_value_gift_recipient_name_value', + required: ['name'] +)] +class GiftRecipientNameValue extends Struct +{ + /** + * Recipient's full name + */ + #[OA\Property(type: 'string')] + protected string $name; + + /** + * Recipient's first name + */ + #[OA\Property(type: 'string')] + protected string $firstName; + + /** + * Recipient's last name + */ + #[OA\Property(type: 'string')] + protected string $lastName; + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public function getFirstName(): string + { + return $this->firstName; + } + + public function setFirstName(string $firstName): void + { + $this->firstName = $firstName; + } + + public function getLastName(): string + { + return $this->lastName; + } + + public function setLastName(string $lastName): void + { + $this->lastName = $lastName; + } +} diff --git a/src/AgentCommerce/Struct/V1/Value/PrivacyConsentValue.php b/src/AgentCommerce/Struct/V1/Value/PrivacyConsentValue.php new file mode 100644 index 000000000..e949b8fdf --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Value/PrivacyConsentValue.php @@ -0,0 +1,113 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1\Value; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Struct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema( + schema: 'paypal_agentic_commerce_v1_value_privacy_consent_value', + required: ['consented'] +)] +class PrivacyConsentValue extends Struct +{ + public const CONSENT_TYPE__DATA_PROCESSING = 'data_processing'; + public const CONSENT_TYPE__MARKETING = 'marketing'; + public const CONSENT_TYPE__THIRD_PARTY_SHARING = 'third_party_sharing'; + public const CONSENT_TYPE__ANALYTICS = 'analytics'; + + /** + * Whether privacy policy was consented to + */ + #[OA\Property(type: 'boolean')] + protected bool $consented; + + /** + * Types of consent given + * + * @var string[] + */ + #[OA\Property( + type: 'array', + items: new OA\Items(type: 'string'), + enum: [self::CONSENT_TYPE__ANALYTICS, self::CONSENT_TYPE__THIRD_PARTY_SHARING, self::CONSENT_TYPE__DATA_PROCESSING, self::CONSENT_TYPE__MARKETING] + )] + protected ?array $consentTypes = null; + + /** + * Privacy policy version + */ + #[OA\Property(type: 'string')] + protected ?string $policyVersion = null; + + /** + * When consent was given + */ + #[OA\Property(type: 'string')] + protected ?string $consentDate = null; + + public function isConsented(): bool + { + return $this->consented; + } + + public function setConsented(bool $consented): void + { + $this->consented = $consented; + } + + /** + * @return ?string[] + */ + public function getConsentTypes(): ?array + { + return $this->consentTypes; + } + + /** + * @param ?string[] $consentTypes + */ + public function setConsentTypes(?array $consentTypes): void + { + $this->consentTypes = $consentTypes; + } + + public function addConsentType(string $consentType): void + { + if (!\in_array($consentType, [self::CONSENT_TYPE__ANALYTICS, self::CONSENT_TYPE__THIRD_PARTY_SHARING, self::CONSENT_TYPE__DATA_PROCESSING, self::CONSENT_TYPE__MARKETING], true)) { + throw new \InvalidArgumentException(\sprintf('Consent type "%s" is not valid.', $consentType)); + } + + $this->consentTypes[] = $consentType; + } + + public function getPolicyVersion(): ?string + { + return $this->policyVersion; + } + + public function setPolicyVersion(?string $policyVersion): void + { + $this->policyVersion = $policyVersion; + } + + public function getConsentDate(): ?string + { + return $this->consentDate; + } + + public function setConsentDate(?string $consentDate): void + { + $this->consentDate = $consentDate; + } +} diff --git a/src/AgentCommerce/Struct/V1/Value/TermsAcceptanceValue.php b/src/AgentCommerce/Struct/V1/Value/TermsAcceptanceValue.php new file mode 100644 index 000000000..abbafe40e --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Value/TermsAcceptanceValue.php @@ -0,0 +1,87 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Struct\V1\Value; + +use OpenApi\Attributes as OA; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Struct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema( + schema: 'paypal_agentic_commerce_v1_value_terms_acceptance_value', + required: ['accepted', 'termsVersions'] +)] +class TermsAcceptanceValue extends Struct +{ + /** + * Whether terms were accepted + */ + #[OA\Property(type: 'boolean')] + protected bool $accepted; + + /** + * Version of terms accepted + */ + #[OA\Property(type: 'string')] + protected string $termsVersions; + + /** + * When terms were accepted + */ + #[OA\Property(type: 'string')] + protected ?string $acceptanceDate = null; + + /** + * IP address of acceptance + */ + #[OA\Property(type: 'string')] + protected ?string $ipAddress = null; + + public function isAccepted(): bool + { + return $this->accepted; + } + + public function setAccepted(bool $accepted): void + { + $this->accepted = $accepted; + } + + public function getTermsVersions(): string + { + return $this->termsVersions; + } + + public function setTermsVersions(string $termsVersions): void + { + $this->termsVersions = $termsVersions; + } + + public function getAcceptanceDate(): ?string + { + return $this->acceptanceDate; + } + + public function setAcceptanceDate(?string $acceptanceDate): void + { + $this->acceptanceDate = $acceptanceDate; + } + + public function getIpAddress(): ?string + { + return $this->ipAddress; + } + + public function setIpAddress(?string $ipAddress): void + { + $this->ipAddress = $ipAddress; + } +} diff --git a/src/AgentCommerce/Util/AgentDebugIDProcessor.php b/src/AgentCommerce/Util/AgentDebugIDProcessor.php new file mode 100644 index 000000000..bf7c97fe1 --- /dev/null +++ b/src/AgentCommerce/Util/AgentDebugIDProcessor.php @@ -0,0 +1,76 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Util; + +use Monolog\LogRecord; +use Monolog\Processor\ProcessorInterface; +use Shopware\Core\Framework\Context; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Routing\RouteScopeCheckTrait; +use Shopware\Core\Framework\Routing\RouteScopeRegistry; +use Shopware\Core\PlatformRequest; +use Swag\PayPal\AgentCommerce\Routing\AgentRouteScope; +use Swag\PayPal\AgentCommerce\Routing\AgentSource; +use Symfony\Component\HttpFoundation\RequestStack; + +/** + * @internal + */ +#[Package('checkout')] +class AgentDebugIDProcessor implements ProcessorInterface +{ + use RouteScopeCheckTrait; + + private RequestStack $requestStack; + + private RouteScopeRegistry $routeScopeRegistry; + + public function __invoke(LogRecord $record): LogRecord + { + $request = $this->requestStack->getMainRequest(); + + if (!$request || !$this->isRequestScoped($request, AgentRouteScope::class)) { + return $record; + } + + $context = $request->attributes->get(PlatformRequest::ATTRIBUTE_CONTEXT_OBJECT); + + if (!$context instanceof Context) { + return $record; + } + + $source = $context->getSource(); + + if (!$source instanceof AgentSource) { + return $record; + } + + if (!$source->debugId) { + return $record; + } + + $record->extra['debugId'] = $source->debugId; + + return $record; + } + + public function setRequestStack(RequestStack $requestStack): void + { + $this->requestStack = $requestStack; + } + + public function setRouteScopeRegistry(RouteScopeRegistry $routeScopeRegistry): void + { + $this->routeScopeRegistry = $routeScopeRegistry; + } + + protected function getScopeRegistry(): RouteScopeRegistry + { + return $this->routeScopeRegistry; + } +} diff --git a/src/AgentCommerce/Validation/HasScopes.php b/src/AgentCommerce/Validation/HasScopes.php new file mode 100644 index 000000000..6482dfee3 --- /dev/null +++ b/src/AgentCommerce/Validation/HasScopes.php @@ -0,0 +1,53 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Validation; + +use Lcobucci\JWT\Token; +use Lcobucci\JWT\UnencryptedToken; +use Lcobucci\JWT\Validation\Constraint; +use Lcobucci\JWT\Validation\ConstraintViolation; +use Shopware\Core\Framework\Log\Package; + +/** + * @internal + */ +#[Package('checkout')] +final class HasScopes implements Constraint +{ + /** + * @param list $expectedScopes + */ + public function __construct(private readonly array $expectedScopes) + { + } + + public function assert(Token $token): void + { + if (!$token instanceof UnencryptedToken) { + throw ConstraintViolation::error('You should pass a plain token', $this); + } + + $claims = $token->claims(); + + if (!$claims->has('scope')) { + throw ConstraintViolation::error('The token does not have the claim "scope"', $this); + } + + $scopes = $claims->get('scope'); + + if (!\is_array($scopes)) { + throw ConstraintViolation::error('The claim "scope" is not an array', $this); + } + + $missingScopes = \array_diff($this->expectedScopes, $scopes); + + if ($missingScopes !== []) { + throw ConstraintViolation::error('The token does not contain the required scopes: "' . \implode(', ', $missingScopes) . '"', $this); + } + } +} diff --git a/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/extension/sw-sales-channel-create/index.ts b/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/extension/sw-sales-channel-create/index.ts index f9d50c665..17f2a9a57 100644 --- a/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/extension/sw-sales-channel-create/index.ts +++ b/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/extension/sw-sales-channel-create/index.ts @@ -1,5 +1,5 @@ -import exportHeader from 'SwagPayPal/module/swag-paypal-agent-commerce/static/product-export/header.txt?raw'; -import exportBody from 'SwagPayPal/module/swag-paypal-agent-commerce/static/product-export/body.txt?raw'; +import exportHeader from 'SwagPayPal/module/swag-paypal-agent-commerce/static/product-export/header.csv.twig?raw'; +import exportBody from 'SwagPayPal/module/swag-paypal-agent-commerce/static/product-export/body.csv.twig?raw'; export default Shopware.Component.wrapComponentConfig<{ salesChannel: TEntity<'sales_channel'>; productExport: TEntity<'product_export'> }>({ methods: { diff --git a/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/static/product-export/body.txt b/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/static/product-export/body.csv.twig similarity index 100% rename from src/Resources/app/administration/src/module/swag-paypal-agent-commerce/static/product-export/body.txt rename to src/Resources/app/administration/src/module/swag-paypal-agent-commerce/static/product-export/body.csv.twig diff --git a/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/static/product-export/header.txt b/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/static/product-export/header.csv.twig similarity index 100% rename from src/Resources/app/administration/src/module/swag-paypal-agent-commerce/static/product-export/header.txt rename to src/Resources/app/administration/src/module/swag-paypal-agent-commerce/static/product-export/header.csv.twig diff --git a/src/Resources/config/services.php b/src/Resources/config/services.php index 8e039f384..634277f4e 100644 --- a/src/Resources/config/services.php +++ b/src/Resources/config/services.php @@ -13,6 +13,7 @@ $loader = new XmlFileLoader($container, new FileLocator(__DIR__ . '/services')); $loader->load('administration.xml'); + $loader->load('agent_commerce.xml'); $loader->load('apm.xml'); $loader->load('checkout.xml'); $loader->load('client.xml'); diff --git a/tests/AgentCommerce/Exception/AgentExceptionTest.php b/tests/AgentCommerce/Exception/AgentExceptionTest.php new file mode 100644 index 000000000..d8bae0c34 --- /dev/null +++ b/tests/AgentCommerce/Exception/AgentExceptionTest.php @@ -0,0 +1,131 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\Tests\AgentCommerce\Exception; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Shopware\Core\Framework\Log\Package; +use Swag\PayPal\AgentCommerce\Exception\AgentException; + +/** + * @internal + */ +#[Package('checkout')] +#[CoversClass(AgentException::class)] +class AgentExceptionTest extends TestCase +{ + public function testRequiredFieldsMissing(): void + { + $exception = AgentException::requiredFieldsMissing('field1', 'field2'); + + static::assertSame(400, $exception->getStatusCode()); + static::assertSame(AgentException::INVALID_REQUEST, $exception->getErrorCode()); + static::assertSame('Required field \'field1, field2\' is missing', $exception->getMessage()); + + $details = $exception->getDetails(); + + static::assertCount(2, $details); + + static::assertSame('field1', $details->first()?->getField()); + static::assertSame('MISSING_REQUIRED_FIELD', $details->first()->getIssue()); + static::assertSame('The field \'field1\' is required and cannot be empty', $details->first()->getDescription()); + + static::assertSame('field2', $details->last()?->getField()); + static::assertSame('MISSING_REQUIRED_FIELD', $details->last()->getIssue()); + static::assertSame('The field \'field2\' is required and cannot be empty', $details->last()->getDescription()); + } + + public function testInvalidJSONFormat(): void + { + $exception = AgentException::invalidJSONFormat(); + + static::assertSame(400, $exception->getStatusCode()); + static::assertSame(AgentException::INVALID_REQUEST, $exception->getErrorCode()); + static::assertSame('Request body contains invalid JSON', $exception->getMessage()); + } + + public function testUnauthorized(): void + { + $exception = AgentException::unauthorized('Invalid JWT token'); + + static::assertSame(401, $exception->getStatusCode()); + static::assertSame(AgentException::INVALID_REQUEST, $exception->getErrorCode()); + static::assertSame('Invalid JWT token', $exception->getMessage()); + } + + public function testCartNotFound(): void + { + $exception = AgentException::cartNotFound('123'); + + static::assertSame(404, $exception->getStatusCode()); + static::assertSame(AgentException::CART_NOT_FOUND, $exception->getErrorCode()); + static::assertSame('Cart with ID \'123\' does not exist', $exception->getMessage()); + } + + public function testDatabaseConnectionFailure(): void + { + $exception = AgentException::databaseConnectionFailure(); + + static::assertSame(500, $exception->getStatusCode()); + static::assertSame(AgentException::INTERNAL_SERVER_ERROR, $exception->getErrorCode()); + static::assertSame('A temporary system error occurred. Please try again later.', $exception->getMessage()); + } + + public function testExternalServiceFailure(): void + { + $exception = AgentException::externalServiceFailure(); + + static::assertSame(500, $exception->getStatusCode()); + static::assertSame(AgentException::SERVICE_UNAVAILABLE, $exception->getErrorCode()); + static::assertSame('The payment processor is currently unavailable. Please try again later.', $exception->getMessage()); + } + + public function testPaymentProcessorUnavailable(): void + { + $exception = AgentException::paymentProcessorUnavailable(); + + static::assertSame(500, $exception->getStatusCode()); + static::assertSame(AgentException::PAYMENT_PROCESSOR_UNAVAILABLE, $exception->getErrorCode()); + static::assertSame('Payment processing is temporarily unavailable', $exception->getMessage()); + } + + public function testPaymentCaptureFailed(): void + { + $exception = AgentException::paymentCaptureFailed('Insufficient funds'); + + static::assertSame(500, $exception->getStatusCode()); + static::assertSame(AgentException::PAYMENT_CAPTURE_FAILED, $exception->getErrorCode()); + static::assertSame('Unable to capture payment at this time', $exception->getMessage()); + + $details = $exception->getDetails(); + + static::assertCount(1, $details); + + static::assertSame('payment_method', $details->first()?->getField()); + static::assertSame('CAPTURE_FAILED', $details->first()->getIssue()); + static::assertSame('Insufficient funds', $details->first()->getDescription()); + } + + public function inventorySystemError(): void + { + $exception = AgentException::inventorySystemError(); + + static::assertSame(500, $exception->getStatusCode()); + static::assertSame(AgentException::INTERNAL_SERVER_ERROR, $exception->getErrorCode()); + static::assertSame('Unable to reserve inventory for checkout', $exception->getMessage()); + } + + public function testOrderSystemError(): void + { + $exception = AgentException::orderSystemError(); + + static::assertSame(500, $exception->getStatusCode()); + static::assertSame(AgentException::ORDER_SYSTEM_ERROR, $exception->getErrorCode()); + static::assertSame('Order could not be created due to system error', $exception->getMessage()); + } +} diff --git a/tests/AgentCommerce/Exception/AgentHttpExceptionTest.php b/tests/AgentCommerce/Exception/AgentHttpExceptionTest.php new file mode 100644 index 000000000..f6f6d2084 --- /dev/null +++ b/tests/AgentCommerce/Exception/AgentHttpExceptionTest.php @@ -0,0 +1,43 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\Tests\AgentCommerce\Exception; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Shopware\Core\Framework\Log\Package; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\AgentErrorDetail; +use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\AgentErrorDetailCollection; +use Shopware\PayPalSDK\Struct\Struct; +use Swag\PayPal\AgentCommerce\Exception\AgentHttpException; + +/** + * @internal + */ +#[Package('checkout')] +#[CoversClass(AgentHttpException::class)] +class AgentHttpExceptionTest extends TestCase +{ + public function testPublicAPI(): void + { + $detail1 = Struct::from(AgentErrorDetail::class, [ + 'field' => 'foo', + 'issue' => 'bar', + 'description' => 'baz', + ]); + + $details = new AgentErrorDetailCollection([$detail1]); + + $e = new class(500, 'TEST_EXCEPTION', 'Test exception message: {{ foo }}', ['foo' => 'bar'], $details) extends AgentHttpException {}; + + static::assertSame(500, $e->getStatusCode()); + static::assertSame('TEST_EXCEPTION', $e->getErrorCode()); + static::assertSame('Test exception message: bar', $e->getMessage()); + static::assertSame(['foo' => 'bar'], $e->getParameters()); + static::assertSame($details, $e->getDetails()); + } +} diff --git a/tests/AgentCommerce/Validation/HasScopesTest.php b/tests/AgentCommerce/Validation/HasScopesTest.php new file mode 100644 index 000000000..ddc99b89b --- /dev/null +++ b/tests/AgentCommerce/Validation/HasScopesTest.php @@ -0,0 +1,139 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\Tests\AgentCommerce\Validation; + +use Lcobucci\JWT\Token; +use Lcobucci\JWT\Validation\ConstraintViolation; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Shopware\Core\Framework\Log\Package; +use Swag\PayPal\AgentCommerce\Validation\HasScopes; + +/** + * @internal + */ +#[Package('checkout')] +#[CoversClass(HasScopes::class)] +class HasScopesTest extends TestCase +{ + public function testAssertWithNonUnencryptedToken(): void + { + $token = new class implements Token { + public function headers(): Token\DataSet + { + return new Token\DataSet([], 'foo'); + } + + public function isPermittedFor(string $audience): bool + { + return false; + } + + public function isIdentifiedBy(string $id): bool + { + return false; + } + + public function isRelatedTo(string $subject): bool + { + return false; + } + + public function hasBeenIssuedBy(string ...$issuers): bool + { + return false; + } + + public function hasBeenIssuedBefore(\DateTimeInterface $now): bool + { + return false; + } + + public function isMinimumTimeBefore(\DateTimeInterface $now): bool + { + return false; + } + + public function isExpired(\DateTimeInterface $now): bool + { + return false; + } + + public function toString(): string + { + return 'foo'; + } + }; + + $constraint = new HasScopes(['scope1', 'scope2']); + + static::expectExceptionObject(ConstraintViolation::error('You should pass a plain token', $constraint)); + + $constraint->assert($token); + } + + public function testAssertWithMissingScopeClaim(): void + { + $token = new Token\Plain( + new Token\DataSet([], 'foo'), + new Token\DataSet([], 'foo'), + new Token\Signature('foo', 'foo') + ); + + $constraint = new HasScopes(['scope1', 'scope2']); + + static::expectExceptionObject(ConstraintViolation::error('The token does not have the claim "scope"', $constraint)); + + $constraint->assert($token); + } + + public function testAssertWithNonArrayScopeClaim(): void + { + $token = new Token\Plain( + new Token\DataSet([], 'foo'), + new Token\DataSet(['scope' => 'non-array'], 'foo'), + new Token\Signature('foo', 'foo') + ); + + $constraint = new HasScopes(['scope1', 'scope2']); + + static::expectExceptionObject(ConstraintViolation::error('The claim "scope" is not an array', $constraint)); + + $constraint->assert($token); + } + + public function testAssertWithMissingScope(): void + { + $token = new Token\Plain( + new Token\DataSet([], 'foo'), + new Token\DataSet(['scope' => ['scope1']], 'foo'), + new Token\Signature('foo', 'foo') + ); + + $constraint = new HasScopes(['scope1', 'scope2', 'scope3']); + + static::expectExceptionObject(ConstraintViolation::error('The token does not contain the required scopes: "scope2, scope3"', $constraint)); + + $constraint->assert($token); + } + + public function testAssert(): void + { + static::expectNotToPerformAssertions(); + + $token = new Token\Plain( + new Token\DataSet([], 'foo'), + new Token\DataSet(['scope' => ['scope1', 'scope2']], ''), + new Token\Signature('foo', 'foo') + ); + + $constraint = new HasScopes(['scope1', 'scope2']); + + $constraint->assert($token); + } +} From 998bed254e2b676ddcd89658076b9414bdbe3d02 Mon Sep 17 00:00:00 2001 From: Fabian Boensch Date: Wed, 29 Oct 2025 11:15:05 +0100 Subject: [PATCH 03/23] ecs-fix & phpstan --- phpstan.neon.dist | 5 - src/AgentCommerce/Exception/JWTException.php | 32 + src/AgentCommerce/HoneyWebhookService.php | 9 +- .../Routing/AgentRequestContextResolver.php | 53 +- src/AgentCommerce/Routing/AgentRouteScope.php | 4 +- .../AbstractAgentCommerceRoute.php | 2 +- .../SalesChannel/CheckoutRoute.php | 22 +- .../SalesChannel/CreateCartRoute.php | 4 +- .../SalesChannel/GetCartRoute.php | 2 +- .../Response/AgentCartResponse.php | 5 +- .../AgentResponseExceptionSubscriber.php | 2 +- .../SalesChannel/UpdateCartRoute.php | 9 +- src/AgentCommerce/Struct/V1/Address.php | 4 +- src/AgentCommerce/Struct/V1/AgentError.php | 4 +- .../Struct/V1/AgentErrorDetail.php | 6 +- .../Struct/V1/AgentErrorDetailCollection.php | 8 +- src/AgentCommerce/Struct/V1/AppliedCoupon.php | 4 +- .../Struct/V1/AppliedCouponCollection.php | 8 +- .../Struct/V1/BillingAddress.php | 5 + src/AgentCommerce/Struct/V1/CartItem.php | 10 +- .../Struct/V1/CartItemCollection.php | 8 +- src/AgentCommerce/Struct/V1/CartTotals.php | 4 +- src/AgentCommerce/Struct/V1/CheckoutField.php | 10 +- .../Struct/V1/CheckoutFieldCollection.php | 8 +- .../Struct/V1/Context/AbstractContext.php | 4 +- .../V1/Context/BusinessRuleErrorContext.php | 3 +- .../Struct/V1/Context/PricingErrorContext.php | 3 +- .../V1/Context/ShippingErrorContext.php | 2 +- src/AgentCommerce/Struct/V1/Coupon.php | 4 +- .../Struct/V1/CouponCollection.php | 8 +- src/AgentCommerce/Struct/V1/Customer.php | 4 +- src/AgentCommerce/Struct/V1/Error.php | 6 +- .../Struct/V1/GeoCoordinates.php | 4 +- src/AgentCommerce/Struct/V1/GiftOptions.php | 4 +- src/AgentCommerce/Struct/V1/Link.php | 4 +- .../Struct/V1/LinkCollection.php | 8 +- src/AgentCommerce/Struct/V1/Money.php | 4 +- src/AgentCommerce/Struct/V1/PayPalCart.php | 18 +- src/AgentCommerce/Struct/V1/PaymentMethod.php | 4 +- src/AgentCommerce/Struct/V1/Phone.php | 4 +- .../Struct/V1/Referral/BusinessHour.php | 4 +- .../V1/Referral/BusinessHourCollection.php | 8 +- .../Struct/V1/Referral/CustomOption.php | 4 +- .../V1/Referral/CustomOptionCollection.php | 8 +- .../Struct/V1/Referral/CustomerName.php | 4 +- .../Struct/V1/Referral/Measurements.php | 4 +- .../Struct/V1/Referral/MetaData.php | 4 +- .../Struct/V1/Referral/MetaDataCollection.php | 8 +- .../Struct/V1/Referral/MixedItem.php | 4 +- .../V1/Referral/MixedItemCollection.php | 8 +- .../Struct/V1/Referral/Recipient.php | 4 +- .../Struct/V1/Referral/SelectedAttribute.php | 4 +- .../Referral/SelectedAttributeCollection.php | 8 +- .../V1/Referral/SuggestedCorrection.php | 4 +- .../SuggestedCorrectionCollection.php | 8 +- .../Struct/V1/ResolutionOption.php | 9 +- .../Struct/V1/ResolutionOptionCollection.php | 8 +- .../Struct/V1/ShippingAddress.php | 5 + .../Struct/V1/ShippingOption.php | 4 +- .../Struct/V1/ShippingOptionCollection.php | 8 +- .../Struct/V1/ValidationIssue.php | 11 +- .../Struct/V1/ValidationIssueCollection.php | 8 +- .../V1/Value/AllergyInformationValue.php | 4 +- .../V1/Value/CustomEngravingTextValue.php | 4 +- .../Struct/V1/Value/CustomSizingInfoValue.php | 4 +- .../V1/Value/DeliveryDatePreferenceValue.php | 4 +- .../V1/Value/DeliveryInstructionsValue.php | 4 +- .../Struct/V1/Value/GiftMessageValue.php | 4 +- .../V1/Value/GiftRecipientEmailValue.php | 4 +- .../V1/Value/GiftRecipientNameValue.php | 4 +- .../Struct/V1/Value/PrivacyConsentValue.php | 4 +- .../Struct/V1/Value/TermsAcceptanceValue.php | 4 +- .../Subscriber/WebhookSubscriber.php | 3 +- src/AgentCommerce/Util/PayPalCartFactory.php | 6 +- .../Util/PayPalCartTransformer.php | 64 +- .../Util/ShopwareCartTransformer.php | 16 +- .../Validation/ValidationIssues.php | 45 +- src/DevOps/Command/GenerateOpenApi.php | 10 +- src/Resources/Schema/AdminApi/openapi.json | 1825 +++++++++++++++++ src/Resources/Schema/StoreApi/openapi.json | 1825 +++++++++++++++++ .../app/administration/src/types/openapi.d.ts | 731 +++++++ .../config/services/agent_commerce.xml | 2 +- src/Util/IntrospectionProcessor.php | 6 + .../Exception/AgentExceptionTest.php | 2 +- .../Exception/AgentHttpExceptionTest.php | 10 +- .../AgentCommerce/HoneyWebhookServiceTest.php | 127 +- .../AgentContextResolverListenerTest.php | 7 +- .../AgentRequestContextResolverTest.php | 32 +- .../Routing/AgentRouteScopeTest.php | 4 +- .../AgentCommerce/Routing/AgentSourceTest.php | 2 +- .../SalesChannel/CheckoutRouteTest.php | 61 +- .../SalesChannel/CreateCartRouteTest.php | 20 +- .../SalesChannel/GetCartRouteTest.php | 6 +- .../Response/AgentCartResponseTest.php | 9 +- .../AgentResponseExceptionSubscriberTest.php | 44 +- .../SalesChannel/UpdateCartRouteTest.php | 73 +- .../ProductFilterSubscriberTest.php | 8 +- .../Subscriber/WebhookSubscriberTest.php | 22 +- .../Util/AgentDebugIDProcessorTest.php | 2 +- .../AgentCommerce/Util/FaviconLoaderTest.php | 22 +- .../Util/PayPalCartFactoryTest.php | 2 +- .../Util/PayPalCartTransformerTest.php | 98 +- .../Util/ShopwareCartTransformerTest.php | 6 +- .../Validation/CartTokenValidatorTest.php | 2 +- .../Validation/HasScopesTest.php | 2 +- .../Validation/ValidationIssuesTest.php | 24 +- tests/OpenAPISchemaTest.php | 10 + tests/Util/IntrospectionProcessorTest.php | 41 + 108 files changed, 5128 insertions(+), 530 deletions(-) create mode 100644 src/AgentCommerce/Exception/JWTException.php diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 24d106454..e427ceab5 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -102,11 +102,6 @@ services: - # register the class, so we can decorate it, but don't tag it as a rule, so only our decorator is used by PHPStan class: Symplify\PHPStanRules\Rules\NoReturnSetterMethodRule - - # Has to be fixed upstream, but we can ignore it for now - message: '#Call to deprecated method __construct\(\) of class Shopware\\Core\\Framework\\DataAbstractionLayer\\EntityDefinition#' - identifier: method.deprecated - path: 'tests/*' - rules: # Shopware core rules - Shopware\Core\DevOps\StaticAnalyze\PHPStan\Rules\Internal\InternalClassRule diff --git a/src/AgentCommerce/Exception/JWTException.php b/src/AgentCommerce/Exception/JWTException.php new file mode 100644 index 000000000..7c5c61e7c --- /dev/null +++ b/src/AgentCommerce/Exception/JWTException.php @@ -0,0 +1,32 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Exception; + +use Shopware\Core\Framework\HttpException; +use Shopware\Core\Framework\Log\Package; +use Symfony\Component\HttpFoundation\Response; + +/** + * @experimental + */ +#[Package('checkout')] +class JWTException extends HttpException +{ + private const INVALID_JWT = 'UTIL__INVALID_JWT'; + + public static function invalidJwt(string $reason, ?\Exception $e = null): self + { + return new self( + Response::HTTP_BAD_REQUEST, + self::INVALID_JWT, + (!str_contains($reason, 'Invalid JWT: ') ? 'Invalid JWT: ' : '') . '{{ message }}', + ['message' => $reason], + $e + ); + } +} diff --git a/src/AgentCommerce/HoneyWebhookService.php b/src/AgentCommerce/HoneyWebhookService.php index 3d69b5997..d26fc8547 100644 --- a/src/AgentCommerce/HoneyWebhookService.php +++ b/src/AgentCommerce/HoneyWebhookService.php @@ -107,11 +107,11 @@ private function createToken(string $salesChannelId, Context $context): string throw HoneyWebhookException::productExportNotFound(); } - $storefront = $productExport->getStorefrontSalesChannel(); - if (!$storefront) { + if (!$productExport->__isset('storefrontSalesChannel')) { throw HoneyWebhookException::storefrontSalesChannelNotFound(); } + $storefront = $productExport->getStorefrontSalesChannel(); $route = $this->router->getRouteCollection()->get('store-api.product.export'); if (!$route) { throw HoneyWebhookException::invalidProductExportRoute(); @@ -152,7 +152,10 @@ private function loadSalesChannel(string $salesChannelId, Context $context): ?Sa 'productExports.storefrontSalesChannel.countries', ]); - return $this->salesChannelRepository->search($criteria, $context)->first(); + /** @var SalesChannelEntity|null $salesChannel */ + $salesChannel = $this->salesChannelRepository->search($criteria, $context)->first(); + + return $salesChannel; } private function webhookCall(string $token, string $endpoint): HoneyWebhookResult diff --git a/src/AgentCommerce/Routing/AgentRequestContextResolver.php b/src/AgentCommerce/Routing/AgentRequestContextResolver.php index 59e0d7c9a..8b311c19e 100644 --- a/src/AgentCommerce/Routing/AgentRequestContextResolver.php +++ b/src/AgentCommerce/Routing/AgentRequestContextResolver.php @@ -7,19 +7,23 @@ namespace Swag\PayPal\AgentCommerce\Routing; +use Lcobucci\JWT\Encoding\JoseEncoder; use Lcobucci\JWT\Signer\Key\InMemory; use Lcobucci\JWT\Signer\Rsa\Sha256; +use Lcobucci\JWT\Token\Parser; +use Lcobucci\JWT\UnencryptedToken; +use Lcobucci\JWT\Validation\Constraint; use Lcobucci\JWT\Validation\Constraint\IssuedBy; use Lcobucci\JWT\Validation\Constraint\LooseValidAt; use Lcobucci\JWT\Validation\Constraint\SignedWith; use Lcobucci\JWT\Validation\RequiredConstraintsViolated; +use Lcobucci\JWT\Validation\Validator; use Shopware\Core\Content\ProductExport\ProductExportCollection; +use Shopware\Core\Content\ProductExport\ProductExportEntity; use Shopware\Core\Framework\Context; 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\JWT\JWTDecoder; -use Shopware\Core\Framework\JWT\JWTException; use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Routing\RequestContextResolverInterface; use Shopware\Core\Framework\Routing\RouteScopeCheckTrait; @@ -33,6 +37,7 @@ use Shopware\Core\System\SalesChannel\Context\SalesChannelContextService; use Shopware\Core\System\SalesChannel\Context\SalesChannelContextServiceParameters; use Swag\PayPal\AgentCommerce\Exception\AgentException; +use Swag\PayPal\AgentCommerce\Exception\JWTException; use Swag\PayPal\AgentCommerce\Validation\CartTokenValidator; use Swag\PayPal\AgentCommerce\Validation\HasScopes; use Swag\PayPal\SwagPayPal; @@ -78,7 +83,6 @@ class AgentRequestContextResolver implements RequestContextResolverInterface public function __construct( private readonly DataValidator $validator, private readonly EntityRepository $productExportRepository, - private readonly JWTDecoder $JWTDecoder, private readonly RouteScopeRegistry $routeScopeRegistry, private readonly SalesChannelContextService $contextService, ) { @@ -111,6 +115,7 @@ public function resolve(Request $request): void new EqualsFilter('salesChannel.id', $source->salesChannelId), ); + /** @var ProductExportEntity|null $productExport */ $productExport = $this->productExportRepository->search($criteria, $context)->first(); if (!$productExport) { throw AgentException::unauthorized('Sales channel not found'); @@ -160,14 +165,14 @@ private function validateJWT(Request $request, string $jwt): void $constraints[] = new HasScopes($scopes); } - $this->JWTDecoder->validate($jwt, ...$constraints); + $this->validate($jwt, ...$constraints); } private function resolveContextSource(string $token): AgentSource { try { /** @var array{external_id: array{0: string}, sub: string, iat: \DateTimeInterface, exp: \DateTimeInterface, scope: list, debug_id?: string} $decoded */ - $decoded = $this->JWTDecoder->decode($token); // @phpstan-ignore varTag.type + $decoded = $this->decode($token); } catch (JWTException $e) { throw AgentException::unauthorized('Invalid JWT token', $e->getPrevious()); } @@ -194,6 +199,42 @@ private static function extractPayPalMerchantId(string $externalId): string { \preg_match('/^PayPal:\s*(.+)$/', $externalId, $matches); - return $matches[1]; + // already checked with DataValidationDefinition + return $matches[1] ?? ''; + } + + private function decode(string $jwt): array + { + return $this->parseToken($jwt)->claims()->all(); + } + + private function validate(string $jwt, Constraint ...$constraints): void + { + try { + $validator = new Validator(); + $validator->assert($this->parseToken($jwt), ...$constraints); + } catch (RequiredConstraintsViolated $e) { + throw JWTException::invalidJwt($e->getMessage(), $e); + } + } + + private function parseToken(string $jwt): UnencryptedToken + { + if (!$jwt) { + throw JWTException::invalidJwt('JWT cannot be empty'); + } + + try { + $parser = new Parser(new JoseEncoder()); + $token = $parser->parse($jwt); + } catch (\Exception $e) { + throw JWTException::invalidJwt($e->getMessage(), $e); + } + + if (!$token instanceof UnencryptedToken) { + throw JWTException::invalidJwt('Incorrect token type'); + } + + return $token; } } diff --git a/src/AgentCommerce/Routing/AgentRouteScope.php b/src/AgentCommerce/Routing/AgentRouteScope.php index 0dfa1af5a..ccd2968ee 100644 --- a/src/AgentCommerce/Routing/AgentRouteScope.php +++ b/src/AgentCommerce/Routing/AgentRouteScope.php @@ -26,9 +26,9 @@ class AgentRouteScope extends AbstractRouteScope /** * @var array * - * @deprecated tag:v6.7.0 - Will be natively typed + * @deprecated tag:v10.0.0 - Will be natively typed */ - protected $allowedPaths = ['api']; + protected $allowedPaths = ['api']; // @phpstan-ignore shopware.propertyNativeType public function isAllowed(Request $request): bool { diff --git a/src/AgentCommerce/SalesChannel/AbstractAgentCommerceRoute.php b/src/AgentCommerce/SalesChannel/AbstractAgentCommerceRoute.php index fa826ab66..24e839980 100644 --- a/src/AgentCommerce/SalesChannel/AbstractAgentCommerceRoute.php +++ b/src/AgentCommerce/SalesChannel/AbstractAgentCommerceRoute.php @@ -13,7 +13,7 @@ use Shopware\Core\System\SalesChannel\Context\SalesChannelContextService; use Shopware\Core\System\SalesChannel\Context\SalesChannelContextServiceParameters; use Shopware\Core\System\SalesChannel\SalesChannelContext; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\PaymentMethod; +use Swag\PayPal\AgentCommerce\Struct\V1\PaymentMethod; /** * @internal diff --git a/src/AgentCommerce/SalesChannel/CheckoutRoute.php b/src/AgentCommerce/SalesChannel/CheckoutRoute.php index 0ddcfcc5b..a7be21ac2 100644 --- a/src/AgentCommerce/SalesChannel/CheckoutRoute.php +++ b/src/AgentCommerce/SalesChannel/CheckoutRoute.php @@ -9,20 +9,20 @@ use Shopware\Core\Checkout\Cart\SalesChannel\AbstractCartOrderRoute; use Shopware\Core\Checkout\Cart\SalesChannel\CartService; -use Shopware\Core\Checkout\Payment\Cart\PaymentTransactionStruct; +use Shopware\Core\Checkout\Payment\Cart\AsyncPaymentTransactionStruct; +use Shopware\Core\Checkout\Payment\PaymentException; use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Validation\DataBag\RequestDataBag; use Shopware\Core\System\SalesChannel\SalesChannelContext; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\PayPalCart; use Swag\PayPal\AgentCommerce\Exception\AgentException; use Swag\PayPal\AgentCommerce\Routing\AgentSource; use Swag\PayPal\AgentCommerce\SalesChannel\Response\AgentCartResponse; +use Swag\PayPal\AgentCommerce\Struct\V1\PayPalCart; use Swag\PayPal\AgentCommerce\Util\PayPalCartTransformer; use Swag\PayPal\AgentCommerce\Validation\CartTokenValidator; use Swag\PayPal\Checkout\Payment\Method\AbstractPaymentMethodHandler; use Swag\PayPal\Checkout\Payment\PayPalPaymentHandler; use Swag\PayPal\RestApi\V2\Resource\OrderResource; -use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Attribute\Route; @@ -57,14 +57,14 @@ public function checkout(string $token, Request $request, SalesChannelContext $c ->order($cart, $context, new RequestDataBag($request->request->all())) ->getOrder(); - $primaryTransactionId = $order->getTransactions()?->last()?->getId(); + $primaryTransaction = $order->getTransactions()?->last(); // @deprecated tag:v11.0.0 - remove if condition with min-version of 6.7.1.0, keep content - if (\method_exists($order, 'getPrimaryTransactionId')) { - $primaryTransactionId = $order->getPrimaryTransactionId(); + if (\method_exists($order, 'getPrimaryOrderTransactionId')) { + $primaryTransaction = $order->getPrimaryOrderTransactionId(); } - if (!$primaryTransactionId) { + if (!$primaryTransaction) { throw AgentException::orderSystemError(); } @@ -77,12 +77,16 @@ public function checkout(string $token, Request $request, SalesChannelContext $c $payPalCart->setStatus(PayPalCart::STATUS__COMPLETE); $payPalCart->setPaymentMethod($this->createPaymentMethod($payPalOrder->getId())); - $response = $this->paymentHandler->pay($request, new PaymentTransactionStruct($primaryTransactionId), $context->getContext(), null); + try { + // @phpstan-ignore new.deprecated + $payment = new AsyncPaymentTransactionStruct($primaryTransaction, $order, ''); + $response = $this->paymentHandler->pay($payment, new RequestDataBag($request->request->all()), $context); - if ($response instanceof RedirectResponse) { $payPalCart->setStatus(PayPalCart::STATUS__INCOMPLETE); $payPalCart->setValidationStatus(PayPalCart::VALIDATION_STATUS__INVALID); $payPalCart->getPaymentMethod()?->setApprovalUrl($response->getTargetUrl()); + } catch (PaymentException $e) { + // TODO: do nothing here? } return new AgentCartResponse($payPalCart); diff --git a/src/AgentCommerce/SalesChannel/CreateCartRoute.php b/src/AgentCommerce/SalesChannel/CreateCartRoute.php index 2aed859b1..8eb11e5c6 100644 --- a/src/AgentCommerce/SalesChannel/CreateCartRoute.php +++ b/src/AgentCommerce/SalesChannel/CreateCartRoute.php @@ -14,15 +14,15 @@ use Shopware\Core\Framework\Validation\DataBag\RequestDataBag; use Shopware\Core\System\SalesChannel\Context\SalesChannelContextService; use Shopware\Core\System\SalesChannel\SalesChannelContext; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\PayPalCart; -use Shopware\PayPalSDK\Struct\V2\Patch; use Swag\PayPal\AgentCommerce\Routing\AgentSource; use Swag\PayPal\AgentCommerce\SalesChannel\Response\AgentCartResponse; +use Swag\PayPal\AgentCommerce\Struct\V1\PayPalCart; use Swag\PayPal\AgentCommerce\Util\PayPalCartFactory; use Swag\PayPal\AgentCommerce\Util\PayPalCartTransformer; use Swag\PayPal\AgentCommerce\Util\ShopwareCartTransformer; use Swag\PayPal\OrdersApi\Builder\AbstractOrderBuilder; use Swag\PayPal\RestApi\PartnerAttributionId; +use Swag\PayPal\RestApi\V2\Api\Patch; use Swag\PayPal\RestApi\V2\Resource\OrderResource; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; diff --git a/src/AgentCommerce/SalesChannel/GetCartRoute.php b/src/AgentCommerce/SalesChannel/GetCartRoute.php index 89cc3ce71..077c3c6ab 100644 --- a/src/AgentCommerce/SalesChannel/GetCartRoute.php +++ b/src/AgentCommerce/SalesChannel/GetCartRoute.php @@ -10,10 +10,10 @@ use Shopware\Core\Checkout\Cart\SalesChannel\CartService; use Shopware\Core\Framework\Log\Package; use Shopware\Core\System\SalesChannel\SalesChannelContext; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\PayPalCart; use Swag\PayPal\AgentCommerce\Exception\AgentException; use Swag\PayPal\AgentCommerce\Routing\AgentSource; use Swag\PayPal\AgentCommerce\SalesChannel\Response\AgentCartResponse; +use Swag\PayPal\AgentCommerce\Struct\V1\PayPalCart; use Swag\PayPal\AgentCommerce\Util\PayPalCartTransformer; use Swag\PayPal\AgentCommerce\Validation\CartTokenValidator; use Symfony\Component\HttpFoundation\Request; diff --git a/src/AgentCommerce/SalesChannel/Response/AgentCartResponse.php b/src/AgentCommerce/SalesChannel/Response/AgentCartResponse.php index ec96f6850..ad3f5354c 100644 --- a/src/AgentCommerce/SalesChannel/Response/AgentCartResponse.php +++ b/src/AgentCommerce/SalesChannel/Response/AgentCartResponse.php @@ -10,11 +10,8 @@ use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Struct\ArrayStruct; use Shopware\Core\System\SalesChannel\StoreApiResponse; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\PayPalCart; +use Swag\PayPal\AgentCommerce\Struct\V1\PayPalCart; -/** - * @extends StoreApiResponse>> - */ #[Package('checkout')] final class AgentCartResponse extends StoreApiResponse { diff --git a/src/AgentCommerce/SalesChannel/Response/AgentResponseExceptionSubscriber.php b/src/AgentCommerce/SalesChannel/Response/AgentResponseExceptionSubscriber.php index e1cd46d07..35db26b75 100644 --- a/src/AgentCommerce/SalesChannel/Response/AgentResponseExceptionSubscriber.php +++ b/src/AgentCommerce/SalesChannel/Response/AgentResponseExceptionSubscriber.php @@ -58,7 +58,7 @@ public function onKernelException(ExceptionEvent $event): void $this->logger->error($exception->getMessage(), ['exception' => $exception]); $source = self::extractSource($event); - $response = new JsonResponse(self::getResponseFromException($exception, $source)); + $response = new JsonResponse($this->getResponseFromException($exception, $source)); $event->setResponse($response); $event->stopPropagation(); diff --git a/src/AgentCommerce/SalesChannel/UpdateCartRoute.php b/src/AgentCommerce/SalesChannel/UpdateCartRoute.php index ea3520020..7c431b8b3 100644 --- a/src/AgentCommerce/SalesChannel/UpdateCartRoute.php +++ b/src/AgentCommerce/SalesChannel/UpdateCartRoute.php @@ -13,15 +13,16 @@ use Shopware\Core\Checkout\Customer\CustomerEntity; use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository; use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\ArrayStruct; use Shopware\Core\Framework\Validation\DataBag\RequestDataBag; use Shopware\Core\Framework\Validation\Exception\ConstraintViolationException; use Shopware\Core\System\SalesChannel\Context\SalesChannelContextService; use Shopware\Core\System\SalesChannel\SalesChannel\ContextSwitchRoute; use Shopware\Core\System\SalesChannel\SalesChannelContext; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\PayPalCart; use Swag\PayPal\AgentCommerce\Exception\AgentException; use Swag\PayPal\AgentCommerce\Routing\AgentSource; use Swag\PayPal\AgentCommerce\SalesChannel\Response\AgentCartResponse; +use Swag\PayPal\AgentCommerce\Struct\V1\PayPalCart; use Swag\PayPal\AgentCommerce\Util\ShopwareCartTransformer; use Swag\PayPal\AgentCommerce\Validation\CartTokenValidator; use Symfony\Component\HttpFoundation\Request; @@ -65,8 +66,10 @@ public function updateCart(string $token, Request $request, SalesChannelContext $response = $this->createCartRoute->createCart($request, $salesChannelContext); if ($response->isSuccessful()) { - if ($response->getObject()->offsetGet('validation_status') === PayPalCart::VALIDATION_STATUS__VALID) { - $response->getObject()->offsetSet('validation_status', PayPalCart::STATUS__READY); + $responseObject = $response->getObject(); + \assert($responseObject instanceof ArrayStruct); + if ($responseObject->offsetGet('validation_status') === PayPalCart::VALIDATION_STATUS__VALID) { + $responseObject->offsetSet('validation_status', PayPalCart::STATUS__READY); } $response->setStatusCode(Response::HTTP_OK); diff --git a/src/AgentCommerce/Struct/V1/Address.php b/src/AgentCommerce/Struct/V1/Address.php index 884adef30..fa67b4dbe 100644 --- a/src/AgentCommerce/Struct/V1/Address.php +++ b/src/AgentCommerce/Struct/V1/Address.php @@ -9,7 +9,7 @@ use OpenApi\Attributes as OA; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Struct; +use Swag\PayPal\RestApi\PayPalApiStruct; /** * @experimental @@ -19,7 +19,7 @@ schema: 'paypal_agentic_commerce_v1_address', required: ['countryCode'] )] -class Address extends Struct +class Address extends PayPalApiStruct { /** * The first line of the address, such as number and street, for example, 173 Drury Lane. diff --git a/src/AgentCommerce/Struct/V1/AgentError.php b/src/AgentCommerce/Struct/V1/AgentError.php index e7b4ce5e3..df5d75f2f 100644 --- a/src/AgentCommerce/Struct/V1/AgentError.php +++ b/src/AgentCommerce/Struct/V1/AgentError.php @@ -8,13 +8,13 @@ namespace Swag\PayPal\AgentCommerce\Struct\V1; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Struct; +use Swag\PayPal\RestApi\PayPalApiStruct; /** * @experimental */ #[Package('checkout')] -class AgentError extends Struct +class AgentError extends PayPalApiStruct { protected string $name; diff --git a/src/AgentCommerce/Struct/V1/AgentErrorDetail.php b/src/AgentCommerce/Struct/V1/AgentErrorDetail.php index fbd2c3495..0df73094a 100644 --- a/src/AgentCommerce/Struct/V1/AgentErrorDetail.php +++ b/src/AgentCommerce/Struct/V1/AgentErrorDetail.php @@ -7,14 +7,16 @@ namespace Swag\PayPal\AgentCommerce\Struct\V1; +use OpenApi\Attributes as OA; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Struct; +use Swag\PayPal\RestApi\PayPalApiStruct; /** * @experimental */ #[Package('checkout')] -class AgentErrorDetail extends Struct +#[OA\Schema(schema: 'paypal_agentic_commerce_v1_agent_error_detail')] +class AgentErrorDetail extends PayPalApiStruct { protected string $field; diff --git a/src/AgentCommerce/Struct/V1/AgentErrorDetailCollection.php b/src/AgentCommerce/Struct/V1/AgentErrorDetailCollection.php index a491c4922..81656b52d 100644 --- a/src/AgentCommerce/Struct/V1/AgentErrorDetailCollection.php +++ b/src/AgentCommerce/Struct/V1/AgentErrorDetailCollection.php @@ -8,17 +8,17 @@ namespace Swag\PayPal\AgentCommerce\Struct\V1; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Collection; +use Swag\PayPal\RestApi\PayPalApiCollection; /** * @experimental * - * @extends Collection + * @extends PayPalApiCollection */ #[Package('checkout')] -class AgentErrorDetailCollection extends Collection +class AgentErrorDetailCollection extends PayPalApiCollection { - protected function getExpectedClass(): string + public static function getExpectedClass(): string { return AgentErrorDetail::class; } diff --git a/src/AgentCommerce/Struct/V1/AppliedCoupon.php b/src/AgentCommerce/Struct/V1/AppliedCoupon.php index 3ea958bd9..33a7bbe15 100644 --- a/src/AgentCommerce/Struct/V1/AppliedCoupon.php +++ b/src/AgentCommerce/Struct/V1/AppliedCoupon.php @@ -9,14 +9,14 @@ use OpenApi\Attributes as OA; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Struct; +use Swag\PayPal\RestApi\PayPalApiStruct; /** * @experimental */ #[Package('checkout')] #[OA\Schema(schema: 'paypal_agentic_commerce_v1_applied_coupon')] -class AppliedCoupon extends Struct +class AppliedCoupon extends PayPalApiStruct { #[OA\Property(type: 'string')] protected ?string $code = null; diff --git a/src/AgentCommerce/Struct/V1/AppliedCouponCollection.php b/src/AgentCommerce/Struct/V1/AppliedCouponCollection.php index 42a066690..2df1bf3d1 100644 --- a/src/AgentCommerce/Struct/V1/AppliedCouponCollection.php +++ b/src/AgentCommerce/Struct/V1/AppliedCouponCollection.php @@ -8,17 +8,17 @@ namespace Swag\PayPal\AgentCommerce\Struct\V1; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Collection; +use Swag\PayPal\RestApi\PayPalApiCollection; /** * @experimental * - * @extends Collection + * @extends PayPalApiCollection */ #[Package('checkout')] -class AppliedCouponCollection extends Collection +class AppliedCouponCollection extends PayPalApiCollection { - protected function getExpectedClass(): string + public static function getExpectedClass(): string { return AppliedCoupon::class; } diff --git a/src/AgentCommerce/Struct/V1/BillingAddress.php b/src/AgentCommerce/Struct/V1/BillingAddress.php index fc3d4cb76..a138465c0 100644 --- a/src/AgentCommerce/Struct/V1/BillingAddress.php +++ b/src/AgentCommerce/Struct/V1/BillingAddress.php @@ -7,6 +7,7 @@ namespace Swag\PayPal\AgentCommerce\Struct\V1; +use OpenApi\Attributes as OA; use Shopware\Core\Framework\Log\Package; /** @@ -45,6 +46,10 @@ * @experimental */ #[Package('checkout')] +#[OA\Schema( + schema: 'paypal_agentic_commerce_v1_billing_address', + required: ['countryCode'] +)] class BillingAddress extends Address { } diff --git a/src/AgentCommerce/Struct/V1/CartItem.php b/src/AgentCommerce/Struct/V1/CartItem.php index e6db8df07..2654ea1f2 100644 --- a/src/AgentCommerce/Struct/V1/CartItem.php +++ b/src/AgentCommerce/Struct/V1/CartItem.php @@ -9,9 +9,11 @@ use OpenApi\Attributes as OA; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Struct; +use Swag\PayPal\AgentCommerce\Struct\V1\Referral\CustomOption; use Swag\PayPal\AgentCommerce\Struct\V1\Referral\CustomOptionCollection; +use Swag\PayPal\AgentCommerce\Struct\V1\Referral\SelectedAttribute; use Swag\PayPal\AgentCommerce\Struct\V1\Referral\SelectedAttributeCollection; +use Swag\PayPal\RestApi\PayPalApiStruct; /** * @experimental @@ -21,7 +23,7 @@ schema: 'paypal_agentic_commerce_v1_cart_item', required: ['itemId', 'quantity', 'price'] )] -class CartItem extends Struct +class CartItem extends PayPalApiStruct { /** * Unique product identifier (optional in v1 for backwards compatibility) @@ -68,13 +70,13 @@ class CartItem extends Struct #[OA\Property(ref: Money::class)] protected ?Money $price = null; - #[OA\Property(ref: SelectedAttributeCollection::class)] + #[OA\Property(type: 'array', items: new OA\Items(ref: SelectedAttribute::class))] protected SelectedAttributeCollection $selectedAttributes; #[OA\Property(ref: GiftOptions::class)] protected ?GiftOptions $giftOptions = null; - #[OA\Property(ref: CustomOptionCollection::class)] + #[OA\Property(type: 'array', items: new OA\Items(ref: CustomOption::class))] protected CustomOptionCollection $customOptions; public function getItemId(): ?string diff --git a/src/AgentCommerce/Struct/V1/CartItemCollection.php b/src/AgentCommerce/Struct/V1/CartItemCollection.php index 48ba40b39..0a6e20331 100644 --- a/src/AgentCommerce/Struct/V1/CartItemCollection.php +++ b/src/AgentCommerce/Struct/V1/CartItemCollection.php @@ -8,17 +8,17 @@ namespace Swag\PayPal\AgentCommerce\Struct\V1; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Collection; +use Swag\PayPal\RestApi\PayPalApiCollection; /** * @experimental * - * @extends Collection + * @extends PayPalApiCollection */ #[Package('checkout')] -class CartItemCollection extends Collection +class CartItemCollection extends PayPalApiCollection { - protected function getExpectedClass(): string + public static function getExpectedClass(): string { return CartItem::class; } diff --git a/src/AgentCommerce/Struct/V1/CartTotals.php b/src/AgentCommerce/Struct/V1/CartTotals.php index 3b5b7038e..4c54118c2 100644 --- a/src/AgentCommerce/Struct/V1/CartTotals.php +++ b/src/AgentCommerce/Struct/V1/CartTotals.php @@ -9,7 +9,7 @@ use OpenApi\Attributes as OA; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Struct; +use Swag\PayPal\RestApi\PayPalApiStruct; /** * @experimental @@ -42,7 +42,7 @@ schema: 'paypal_agentic_commerce_v1_cart_totals', required: ['total'] )] -class CartTotals extends Struct +class CartTotals extends PayPalApiStruct { #[OA\Property(ref: Money::class)] protected ?Money $subtotal = null; diff --git a/src/AgentCommerce/Struct/V1/CheckoutField.php b/src/AgentCommerce/Struct/V1/CheckoutField.php index bd4b1cf15..3546c4524 100644 --- a/src/AgentCommerce/Struct/V1/CheckoutField.php +++ b/src/AgentCommerce/Struct/V1/CheckoutField.php @@ -9,7 +9,6 @@ use OpenApi\Attributes as OA; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Struct; use Swag\PayPal\AgentCommerce\Struct\V1\Value\AgeVerificationValue; use Swag\PayPal\AgentCommerce\Struct\V1\Value\AllergyInformationValue; use Swag\PayPal\AgentCommerce\Struct\V1\Value\CustomEngravingTextValue; @@ -21,6 +20,7 @@ use Swag\PayPal\AgentCommerce\Struct\V1\Value\GiftRecipientNameValue; use Swag\PayPal\AgentCommerce\Struct\V1\Value\PrivacyConsentValue; use Swag\PayPal\AgentCommerce\Struct\V1\Value\TermsAcceptanceValue; +use Swag\PayPal\RestApi\PayPalApiStruct; /** * @experimental @@ -41,7 +41,7 @@ schema: 'paypal_agentic_commerce_v1_checkout_field', required: ['type', 'status'] )] -class CheckoutField extends Struct +class CheckoutField extends PayPalApiStruct { public const TYPE__AGE_VERIFICATION_18_PLUS = 'AGE_VERIFICATION_18_PLUS'; public const TYPE__AGE_VERIFICATION_21_PLUS = 'AGE_VERIFICATION_21_PLUS'; @@ -142,7 +142,7 @@ enum: self::STATUSES new OA\Schema(ref: TermsAcceptanceValue::class), new OA\Schema(ref: PrivacyConsentValue::class), ])] - protected Struct $value; + protected PayPalApiStruct $value; /** * Additional context and metadata for the checkout field. @@ -183,12 +183,12 @@ public function setStatus(string $status): void $this->status = $status; } - public function getValue(): Struct + public function getValue(): PayPalApiStruct { return $this->value; } - public function setValue(Struct $value): void + public function setValue(PayPalApiStruct $value): void { $this->value = $value; } diff --git a/src/AgentCommerce/Struct/V1/CheckoutFieldCollection.php b/src/AgentCommerce/Struct/V1/CheckoutFieldCollection.php index 3f447b656..3332cc6ab 100644 --- a/src/AgentCommerce/Struct/V1/CheckoutFieldCollection.php +++ b/src/AgentCommerce/Struct/V1/CheckoutFieldCollection.php @@ -8,17 +8,17 @@ namespace Swag\PayPal\AgentCommerce\Struct\V1; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Collection; +use Swag\PayPal\RestApi\PayPalApiCollection; /** * @experimental * - * @extends Collection + * @extends PayPalApiCollection */ #[Package('checkout')] -class CheckoutFieldCollection extends Collection +class CheckoutFieldCollection extends PayPalApiCollection { - protected function getExpectedClass(): string + public static function getExpectedClass(): string { return CheckoutField::class; } diff --git a/src/AgentCommerce/Struct/V1/Context/AbstractContext.php b/src/AgentCommerce/Struct/V1/Context/AbstractContext.php index b07879e1a..9fad69b7c 100644 --- a/src/AgentCommerce/Struct/V1/Context/AbstractContext.php +++ b/src/AgentCommerce/Struct/V1/Context/AbstractContext.php @@ -9,13 +9,13 @@ use OpenApi\Attributes as OA; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Struct; +use Swag\PayPal\RestApi\PayPalApiStruct; /** * @experimental */ #[Package('checkout')] -abstract class AbstractContext extends Struct +abstract class AbstractContext extends PayPalApiStruct { /** * Specific business rule issue type diff --git a/src/AgentCommerce/Struct/V1/Context/BusinessRuleErrorContext.php b/src/AgentCommerce/Struct/V1/Context/BusinessRuleErrorContext.php index 0a5f9c8da..b3f214cdf 100644 --- a/src/AgentCommerce/Struct/V1/Context/BusinessRuleErrorContext.php +++ b/src/AgentCommerce/Struct/V1/Context/BusinessRuleErrorContext.php @@ -9,6 +9,7 @@ use OpenApi\Attributes as OA; use Shopware\Core\Framework\Log\Package; +use Swag\PayPal\AgentCommerce\Struct\V1\Referral\BusinessHour; use Swag\PayPal\AgentCommerce\Struct\V1\Referral\BusinessHourCollection; /** @@ -147,7 +148,7 @@ class BusinessRuleErrorContext extends AbstractContext /** * Store business hours */ - #[OA\Property(ref: BusinessHourCollection::class)] + #[OA\Property(type: 'array', items: new OA\Items(ref: BusinessHour::class))] protected BusinessHourCollection $businessHours; /** diff --git a/src/AgentCommerce/Struct/V1/Context/PricingErrorContext.php b/src/AgentCommerce/Struct/V1/Context/PricingErrorContext.php index f40434841..5e51ce39e 100644 --- a/src/AgentCommerce/Struct/V1/Context/PricingErrorContext.php +++ b/src/AgentCommerce/Struct/V1/Context/PricingErrorContext.php @@ -9,6 +9,7 @@ use OpenApi\Attributes as OA; use Shopware\Core\Framework\Log\Package; +use Swag\PayPal\AgentCommerce\Struct\V1\Referral\MixedItem; use Swag\PayPal\AgentCommerce\Struct\V1\Referral\MixedItemCollection; /** @@ -170,7 +171,7 @@ enum: self::PRICE_CHANGED_REASONS, /** * Items with different currencies */ - #[OA\Property(ref: MixedItemCollection::class)] + #[OA\Property(type: 'array', items: new OA\Items(ref: MixedItem::class))] protected MixedItemCollection $mixedItems; public function getItemId(): ?string diff --git a/src/AgentCommerce/Struct/V1/Context/ShippingErrorContext.php b/src/AgentCommerce/Struct/V1/Context/ShippingErrorContext.php index 7a7b2c87f..65d530d0b 100644 --- a/src/AgentCommerce/Struct/V1/Context/ShippingErrorContext.php +++ b/src/AgentCommerce/Struct/V1/Context/ShippingErrorContext.php @@ -60,7 +60,7 @@ class ShippingErrorContext extends AbstractContext /** * Suggested address corrections */ - #[OA\Property(ref: SuggestedCorrectionCollection::class)] + #[OA\Property(type: 'array', items: new OA\Items(ref: SuggestedCorrection::class))] protected SuggestedCorrectionCollection $suggestedCorrections; /** diff --git a/src/AgentCommerce/Struct/V1/Coupon.php b/src/AgentCommerce/Struct/V1/Coupon.php index 8ca5889b1..6a8c76c6b 100644 --- a/src/AgentCommerce/Struct/V1/Coupon.php +++ b/src/AgentCommerce/Struct/V1/Coupon.php @@ -9,7 +9,7 @@ use OpenApi\Attributes as OA; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Struct; +use Swag\PayPal\RestApi\PayPalApiStruct; /** * @experimental @@ -30,7 +30,7 @@ schema: 'paypal_agentic_commerce_v1_coupon', required: ['code', 'action'] )] -class Coupon extends Struct +class Coupon extends PayPalApiStruct { public const APPLY = 'APPLY'; public const REMOVE = 'REMOVE'; diff --git a/src/AgentCommerce/Struct/V1/CouponCollection.php b/src/AgentCommerce/Struct/V1/CouponCollection.php index 985eb92c9..9181f32a5 100644 --- a/src/AgentCommerce/Struct/V1/CouponCollection.php +++ b/src/AgentCommerce/Struct/V1/CouponCollection.php @@ -8,17 +8,17 @@ namespace Swag\PayPal\AgentCommerce\Struct\V1; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Collection; +use Swag\PayPal\RestApi\PayPalApiCollection; /** * @experimental * - * @extends Collection + * @extends PayPalApiCollection */ #[Package('checkout')] -class CouponCollection extends Collection +class CouponCollection extends PayPalApiCollection { - protected function getExpectedClass(): string + public static function getExpectedClass(): string { return Coupon::class; } diff --git a/src/AgentCommerce/Struct/V1/Customer.php b/src/AgentCommerce/Struct/V1/Customer.php index 76c7a53ca..769698bf5 100644 --- a/src/AgentCommerce/Struct/V1/Customer.php +++ b/src/AgentCommerce/Struct/V1/Customer.php @@ -9,15 +9,15 @@ use OpenApi\Attributes as OA; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Struct; use Swag\PayPal\AgentCommerce\Struct\V1\Referral\CustomerName; +use Swag\PayPal\RestApi\PayPalApiStruct; /** * @experimental */ #[Package('checkout')] #[OA\Schema(schema: 'paypal_agentic_commerce_v1_customer')] -class Customer extends Struct +class Customer extends PayPalApiStruct { #[OA\Property(ref: CustomerName::class)] protected CustomerName $name; diff --git a/src/AgentCommerce/Struct/V1/Error.php b/src/AgentCommerce/Struct/V1/Error.php index cdd0869fb..15b542899 100644 --- a/src/AgentCommerce/Struct/V1/Error.php +++ b/src/AgentCommerce/Struct/V1/Error.php @@ -9,7 +9,7 @@ use OpenApi\Attributes as OA; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Struct; +use Swag\PayPal\RestApi\PayPalApiStruct; /** * @experimental @@ -19,7 +19,7 @@ schema: 'paypal_agentic_commerce_v1_error', required: ['name', 'message'] )] -class Error extends Struct +class Error extends PayPalApiStruct { /** * Error name/type @@ -42,7 +42,7 @@ class Error extends Struct /** * Detailed error information */ - #[OA\Property(ref: AgentErrorDetailCollection::class)] + #[OA\Property(type: 'array', items: new OA\Items(ref: AgentErrorDetail::class))] protected AgentErrorDetailCollection $details; public function getName(): string diff --git a/src/AgentCommerce/Struct/V1/GeoCoordinates.php b/src/AgentCommerce/Struct/V1/GeoCoordinates.php index 3bf43bf9c..5b5cf5f6e 100644 --- a/src/AgentCommerce/Struct/V1/GeoCoordinates.php +++ b/src/AgentCommerce/Struct/V1/GeoCoordinates.php @@ -9,14 +9,14 @@ use OpenApi\Attributes as OA; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Struct; +use Swag\PayPal\RestApi\PayPalApiStruct; /** * @experimental */ #[Package('checkout')] #[OA\Schema(schema: 'paypal_agentic_commerce_v1_geo_coordinates')] -class GeoCoordinates extends Struct +class GeoCoordinates extends PayPalApiStruct { /** * Latitude coordinate in decimal degrees (-90 to 90). WGS84 datum. diff --git a/src/AgentCommerce/Struct/V1/GiftOptions.php b/src/AgentCommerce/Struct/V1/GiftOptions.php index 5b1438602..ea6d75555 100644 --- a/src/AgentCommerce/Struct/V1/GiftOptions.php +++ b/src/AgentCommerce/Struct/V1/GiftOptions.php @@ -9,15 +9,15 @@ use OpenApi\Attributes as OA; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Struct; use Swag\PayPal\AgentCommerce\Struct\V1\Referral\Recipient; +use Swag\PayPal\RestApi\PayPalApiStruct; /** * @experimental */ #[Package('checkout')] #[OA\Schema(schema: 'paypal_agentic_commerce_v1_gift_options')] -class GiftOptions extends Struct +class GiftOptions extends PayPalApiStruct { /** * Whether this is a gift diff --git a/src/AgentCommerce/Struct/V1/Link.php b/src/AgentCommerce/Struct/V1/Link.php index f81c22bd1..e214ddb97 100644 --- a/src/AgentCommerce/Struct/V1/Link.php +++ b/src/AgentCommerce/Struct/V1/Link.php @@ -9,7 +9,7 @@ use OpenApi\Attributes as OA; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Struct; +use Swag\PayPal\RestApi\PayPalApiStruct; /** * @experimental @@ -19,7 +19,7 @@ schema: 'paypal_agentic_commerce_v1_link', required: ['rel', 'href'] )] -class Link extends Struct +class Link extends PayPalApiStruct { public const REL__SELF = 'rel'; public const REL__UPDATE = 'update'; diff --git a/src/AgentCommerce/Struct/V1/LinkCollection.php b/src/AgentCommerce/Struct/V1/LinkCollection.php index 9107f3212..11d63e22d 100644 --- a/src/AgentCommerce/Struct/V1/LinkCollection.php +++ b/src/AgentCommerce/Struct/V1/LinkCollection.php @@ -8,17 +8,17 @@ namespace Swag\PayPal\AgentCommerce\Struct\V1; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Collection; +use Swag\PayPal\RestApi\PayPalApiCollection; /** * @experimental * - * @extends Collection + * @extends PayPalApiCollection */ #[Package('checkout')] -class LinkCollection extends Collection +class LinkCollection extends PayPalApiCollection { - protected function getExpectedClass(): string + public static function getExpectedClass(): string { return Link::class; } diff --git a/src/AgentCommerce/Struct/V1/Money.php b/src/AgentCommerce/Struct/V1/Money.php index eaf862f7d..ca38860a7 100644 --- a/src/AgentCommerce/Struct/V1/Money.php +++ b/src/AgentCommerce/Struct/V1/Money.php @@ -9,7 +9,7 @@ use OpenApi\Attributes as OA; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Struct; +use Swag\PayPal\RestApi\PayPalApiStruct; /** * @experimental @@ -19,7 +19,7 @@ schema: 'paypal_agentic_commerce_v1_money', required: ['currencyCode', 'value'] )] -class Money extends Struct +class Money extends PayPalApiStruct { /** * The 3-character ISO-4217 currency code that identifies the currency. diff --git a/src/AgentCommerce/Struct/V1/PayPalCart.php b/src/AgentCommerce/Struct/V1/PayPalCart.php index b13444d35..7cb97aff8 100644 --- a/src/AgentCommerce/Struct/V1/PayPalCart.php +++ b/src/AgentCommerce/Struct/V1/PayPalCart.php @@ -9,7 +9,7 @@ use OpenApi\Attributes as OA; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Struct; +use Swag\PayPal\RestApi\PayPalApiStruct; /** * @experimental @@ -19,7 +19,7 @@ schema: 'paypal_agentic_commerce_v1_pay_pal_cart', required: ['items', 'paymentMethod'] )] -class PayPalCart extends Struct +class PayPalCart extends PayPalApiStruct { public const STATUS__CREATED = 'CREATED'; public const STATUS__INCOMPLETE = 'INCOMPLETE'; @@ -80,7 +80,7 @@ enum: self::VALIDATION_STATUSES, /** * List of issues preventing checkout (empty = ready) */ - #[OA\Property(ref: ValidationIssueCollection::class)] + #[OA\Property(type: 'array', items: new OA\Items(ref: ValidationIssue::class))] protected ValidationIssueCollection $validationIssues; #[OA\Property(ref: CartTotals::class)] @@ -89,25 +89,25 @@ enum: self::VALIDATION_STATUSES, /** * Successfully applied coupons (server-calculated) */ - #[OA\Property(ref: AppliedCouponCollection::class)] + #[OA\Property(type: 'array', items: new OA\Items(ref: AppliedCoupon::class))] protected ?AppliedCouponCollection $appliedCoupons = null; /** * Available shipping methods with selection state */ - #[OA\Property(ref: ShippingOptionCollection::class)] + #[OA\Property(type: 'array', items: new OA\Items(ref: ShippingOption::class))] protected ?ShippingOptionCollection $availableShippingOptions = null; /** * HATEOAS navigation links for cart operations */ - #[OA\Property(ref: LinkCollection::class)] + #[OA\Property(type: 'array', items: new OA\Items(ref: Link::class))] protected ?LinkCollection $links = null; /** * Products in the cart */ - #[OA\Property(ref: CartItemCollection::class)] + #[OA\Property(type: 'array', items: new OA\Items(ref: CartItem::class))] protected CartItemCollection $items; #[OA\Property(ref: Customer::class)] @@ -125,13 +125,13 @@ enum: self::VALIDATION_STATUSES, /** * Custom checkout fields (age verification, etc.) */ - #[OA\Property(ref: CheckoutFieldCollection::class)] + #[OA\Property(type: 'array', items: new OA\Items(ref: CheckoutField::class))] protected ?CheckoutFieldCollection $checkoutFields = null; /** * Discount coupons to apply or remove from cart */ - #[OA\Property(ref: CouponCollection::class)] + #[OA\Property(type: 'array', items: new OA\Items(ref: Coupon::class))] protected CouponCollection $coupons; #[OA\Property(ref: GeoCoordinates::class)] diff --git a/src/AgentCommerce/Struct/V1/PaymentMethod.php b/src/AgentCommerce/Struct/V1/PaymentMethod.php index 90510a22d..cca23ffd5 100644 --- a/src/AgentCommerce/Struct/V1/PaymentMethod.php +++ b/src/AgentCommerce/Struct/V1/PaymentMethod.php @@ -9,7 +9,7 @@ use OpenApi\Attributes as OA; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Struct; +use Swag\PayPal\RestApi\PayPalApiStruct; /** * @experimental @@ -37,7 +37,7 @@ schema: 'paypal_agentic_commerce_v1_payment_method', required: ['type'] )] -class PaymentMethod extends Struct +class PaymentMethod extends PayPalApiStruct { /** * Payment method type - only PayPal is supported by this API diff --git a/src/AgentCommerce/Struct/V1/Phone.php b/src/AgentCommerce/Struct/V1/Phone.php index 35381b0f7..fa3efc4e8 100644 --- a/src/AgentCommerce/Struct/V1/Phone.php +++ b/src/AgentCommerce/Struct/V1/Phone.php @@ -9,7 +9,7 @@ use OpenApi\Attributes as OA; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Struct; +use Swag\PayPal\RestApi\PayPalApiStruct; /** * @experimental @@ -19,7 +19,7 @@ schema: 'paypal_agentic_commerce_v1_phone', required: ['countryCode', 'nationalNumber'] )] -class Phone extends Struct +class Phone extends PayPalApiStruct { private const PHONE_NUMBER_REGEX = '/\+(\d{1,3})\s(\d{1,14})(-?(\d{1,15}))?/'; diff --git a/src/AgentCommerce/Struct/V1/Referral/BusinessHour.php b/src/AgentCommerce/Struct/V1/Referral/BusinessHour.php index 3200c8445..024152782 100644 --- a/src/AgentCommerce/Struct/V1/Referral/BusinessHour.php +++ b/src/AgentCommerce/Struct/V1/Referral/BusinessHour.php @@ -9,14 +9,14 @@ use OpenApi\Attributes as OA; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Struct; +use Swag\PayPal\RestApi\PayPalApiStruct; /** * @experimental */ #[Package('checkout')] #[OA\Schema(schema: 'paypal_agentic_commerce_v1_referral_business_hour')] -class BusinessHour extends Struct +class BusinessHour extends PayPalApiStruct { #[OA\Property(type: 'string')] protected string $openTime; diff --git a/src/AgentCommerce/Struct/V1/Referral/BusinessHourCollection.php b/src/AgentCommerce/Struct/V1/Referral/BusinessHourCollection.php index ed58b58d5..c3c36ce87 100644 --- a/src/AgentCommerce/Struct/V1/Referral/BusinessHourCollection.php +++ b/src/AgentCommerce/Struct/V1/Referral/BusinessHourCollection.php @@ -8,17 +8,17 @@ namespace Swag\PayPal\AgentCommerce\Struct\V1\Referral; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Collection; +use Swag\PayPal\RestApi\PayPalApiCollection; /** * @experimental * - * @extends Collection + * @extends PayPalApiCollection */ #[Package('checkout')] -class BusinessHourCollection extends Collection +class BusinessHourCollection extends PayPalApiCollection { - protected function getExpectedClass(): string + public static function getExpectedClass(): string { return BusinessHour::class; } diff --git a/src/AgentCommerce/Struct/V1/Referral/CustomOption.php b/src/AgentCommerce/Struct/V1/Referral/CustomOption.php index 272f4a250..ad5cbc983 100644 --- a/src/AgentCommerce/Struct/V1/Referral/CustomOption.php +++ b/src/AgentCommerce/Struct/V1/Referral/CustomOption.php @@ -9,14 +9,14 @@ use OpenApi\Attributes as OA; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Struct; +use Swag\PayPal\RestApi\PayPalApiStruct; /** * @experimental */ #[Package('checkout')] #[OA\Schema(schema: 'paypal_agentic_commerce_v1_referral_custom_option')] -class CustomOption extends Struct +class CustomOption extends PayPalApiStruct { #[OA\Property(type: 'string')] protected string $name; diff --git a/src/AgentCommerce/Struct/V1/Referral/CustomOptionCollection.php b/src/AgentCommerce/Struct/V1/Referral/CustomOptionCollection.php index 07a474e84..12a7ea124 100644 --- a/src/AgentCommerce/Struct/V1/Referral/CustomOptionCollection.php +++ b/src/AgentCommerce/Struct/V1/Referral/CustomOptionCollection.php @@ -8,17 +8,17 @@ namespace Swag\PayPal\AgentCommerce\Struct\V1\Referral; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Collection; +use Swag\PayPal\RestApi\PayPalApiCollection; /** * @experimental * - * @extends Collection + * @extends PayPalApiCollection */ #[Package('checkout')] -class CustomOptionCollection extends Collection +class CustomOptionCollection extends PayPalApiCollection { - protected function getExpectedClass(): string + public static function getExpectedClass(): string { return CustomOption::class; } diff --git a/src/AgentCommerce/Struct/V1/Referral/CustomerName.php b/src/AgentCommerce/Struct/V1/Referral/CustomerName.php index e60bdd465..b37a67036 100644 --- a/src/AgentCommerce/Struct/V1/Referral/CustomerName.php +++ b/src/AgentCommerce/Struct/V1/Referral/CustomerName.php @@ -9,14 +9,14 @@ use OpenApi\Attributes as OA; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Struct; +use Swag\PayPal\RestApi\PayPalApiStruct; /** * @experimental */ #[Package('checkout')] #[OA\Schema(schema: 'paypal_agentic_commerce_v1_referral_customer_name')] -class CustomerName extends Struct +class CustomerName extends PayPalApiStruct { #[OA\Property(type: 'string')] protected string $givenName; diff --git a/src/AgentCommerce/Struct/V1/Referral/Measurements.php b/src/AgentCommerce/Struct/V1/Referral/Measurements.php index 127178222..5afe66831 100644 --- a/src/AgentCommerce/Struct/V1/Referral/Measurements.php +++ b/src/AgentCommerce/Struct/V1/Referral/Measurements.php @@ -9,14 +9,14 @@ use OpenApi\Attributes as OA; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Struct; +use Swag\PayPal\RestApi\PayPalApiStruct; /** * @experimental */ #[Package('checkout')] #[OA\Schema(schema: 'paypal_agentic_commerce_v1_referral_measurements')] -class Measurements extends Struct +class Measurements extends PayPalApiStruct { #[OA\Property(type: 'string')] protected string $chest; diff --git a/src/AgentCommerce/Struct/V1/Referral/MetaData.php b/src/AgentCommerce/Struct/V1/Referral/MetaData.php index dc84db592..1834cf54b 100644 --- a/src/AgentCommerce/Struct/V1/Referral/MetaData.php +++ b/src/AgentCommerce/Struct/V1/Referral/MetaData.php @@ -9,14 +9,14 @@ use OpenApi\Attributes as OA; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Struct; +use Swag\PayPal\RestApi\PayPalApiStruct; /** * @experimental */ #[Package('checkout')] #[OA\Schema(schema: 'paypal_agentic_commerce_v1_referral_meta_data')] -class MetaData extends Struct +class MetaData extends PayPalApiStruct { public const PRIORITY__HIGH = 'HIGH'; public const PRIORITY__MEDIUM = 'MEDIUM'; diff --git a/src/AgentCommerce/Struct/V1/Referral/MetaDataCollection.php b/src/AgentCommerce/Struct/V1/Referral/MetaDataCollection.php index 76ece4006..ebaf49d9a 100644 --- a/src/AgentCommerce/Struct/V1/Referral/MetaDataCollection.php +++ b/src/AgentCommerce/Struct/V1/Referral/MetaDataCollection.php @@ -8,17 +8,17 @@ namespace Swag\PayPal\AgentCommerce\Struct\V1\Referral; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Collection; +use Swag\PayPal\RestApi\PayPalApiCollection; /** * @experimental * - * @extends Collection + * @extends PayPalApiCollection */ #[Package('checkout')] -class MetaDataCollection extends Collection +class MetaDataCollection extends PayPalApiCollection { - protected function getExpectedClass(): string + public static function getExpectedClass(): string { return MetaData::class; } diff --git a/src/AgentCommerce/Struct/V1/Referral/MixedItem.php b/src/AgentCommerce/Struct/V1/Referral/MixedItem.php index 1e83dc379..a105c2a85 100644 --- a/src/AgentCommerce/Struct/V1/Referral/MixedItem.php +++ b/src/AgentCommerce/Struct/V1/Referral/MixedItem.php @@ -9,14 +9,14 @@ use OpenApi\Attributes as OA; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Struct; +use Swag\PayPal\RestApi\PayPalApiStruct; /** * @experimental */ #[Package('checkout')] #[OA\Schema(schema: 'paypal_agentic_commerce_v1_referral_mixed_item')] -class MixedItem extends Struct +class MixedItem extends PayPalApiStruct { #[OA\Property(type: 'string')] protected string $itemId; diff --git a/src/AgentCommerce/Struct/V1/Referral/MixedItemCollection.php b/src/AgentCommerce/Struct/V1/Referral/MixedItemCollection.php index 123ea0fa0..267411a89 100644 --- a/src/AgentCommerce/Struct/V1/Referral/MixedItemCollection.php +++ b/src/AgentCommerce/Struct/V1/Referral/MixedItemCollection.php @@ -8,17 +8,17 @@ namespace Swag\PayPal\AgentCommerce\Struct\V1\Referral; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Collection; +use Swag\PayPal\RestApi\PayPalApiCollection; /** * @experimental * - * @extends Collection + * @extends PayPalApiCollection */ #[Package('checkout')] -class MixedItemCollection extends Collection +class MixedItemCollection extends PayPalApiCollection { - protected function getExpectedClass(): string + public static function getExpectedClass(): string { return MixedItem::class; } diff --git a/src/AgentCommerce/Struct/V1/Referral/Recipient.php b/src/AgentCommerce/Struct/V1/Referral/Recipient.php index be9778c2b..29b413f07 100644 --- a/src/AgentCommerce/Struct/V1/Referral/Recipient.php +++ b/src/AgentCommerce/Struct/V1/Referral/Recipient.php @@ -9,14 +9,14 @@ use OpenApi\Attributes as OA; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Struct; +use Swag\PayPal\RestApi\PayPalApiStruct; /** * @experimental */ #[Package('checkout')] #[OA\Schema(schema: 'paypal_agentic_commerce_v1_referral_recipient')] -class Recipient extends Struct +class Recipient extends PayPalApiStruct { #[OA\Property(type: 'string')] protected string $name; diff --git a/src/AgentCommerce/Struct/V1/Referral/SelectedAttribute.php b/src/AgentCommerce/Struct/V1/Referral/SelectedAttribute.php index fa55ea06d..e982b66a4 100644 --- a/src/AgentCommerce/Struct/V1/Referral/SelectedAttribute.php +++ b/src/AgentCommerce/Struct/V1/Referral/SelectedAttribute.php @@ -9,14 +9,14 @@ use OpenApi\Attributes as OA; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Struct; +use Swag\PayPal\RestApi\PayPalApiStruct; /** * @experimental */ #[Package('checkout')] #[OA\Schema(schema: 'paypal_agentic_commerce_v1_referral_selected_attribute')] -class SelectedAttribute extends Struct +class SelectedAttribute extends PayPalApiStruct { #[OA\Property(type: 'string')] protected string $name; diff --git a/src/AgentCommerce/Struct/V1/Referral/SelectedAttributeCollection.php b/src/AgentCommerce/Struct/V1/Referral/SelectedAttributeCollection.php index 55417c403..7926bb943 100644 --- a/src/AgentCommerce/Struct/V1/Referral/SelectedAttributeCollection.php +++ b/src/AgentCommerce/Struct/V1/Referral/SelectedAttributeCollection.php @@ -8,17 +8,17 @@ namespace Swag\PayPal\AgentCommerce\Struct\V1\Referral; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Collection; +use Swag\PayPal\RestApi\PayPalApiCollection; /** * @experimental * - * @extends Collection + * @extends PayPalApiCollection */ #[Package('checkout')] -class SelectedAttributeCollection extends Collection +class SelectedAttributeCollection extends PayPalApiCollection { - protected function getExpectedClass(): string + public static function getExpectedClass(): string { return SelectedAttribute::class; } diff --git a/src/AgentCommerce/Struct/V1/Referral/SuggestedCorrection.php b/src/AgentCommerce/Struct/V1/Referral/SuggestedCorrection.php index 9623e98cd..202d45133 100644 --- a/src/AgentCommerce/Struct/V1/Referral/SuggestedCorrection.php +++ b/src/AgentCommerce/Struct/V1/Referral/SuggestedCorrection.php @@ -9,14 +9,14 @@ use OpenApi\Attributes as OA; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Struct; +use Swag\PayPal\RestApi\PayPalApiStruct; /** * @experimental */ #[Package('checkout')] #[OA\Schema(schema: 'paypal_agentic_commerce_v1_referral_suggested_correction')] -class SuggestedCorrection extends Struct +class SuggestedCorrection extends PayPalApiStruct { #[OA\Property(type: 'string')] protected string $postalCode; diff --git a/src/AgentCommerce/Struct/V1/Referral/SuggestedCorrectionCollection.php b/src/AgentCommerce/Struct/V1/Referral/SuggestedCorrectionCollection.php index d3cebc1da..caf1b6206 100644 --- a/src/AgentCommerce/Struct/V1/Referral/SuggestedCorrectionCollection.php +++ b/src/AgentCommerce/Struct/V1/Referral/SuggestedCorrectionCollection.php @@ -8,17 +8,17 @@ namespace Swag\PayPal\AgentCommerce\Struct\V1\Referral; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Collection; +use Swag\PayPal\RestApi\PayPalApiCollection; /** * @experimental * - * @extends Collection + * @extends PayPalApiCollection */ #[Package('checkout')] -class SuggestedCorrectionCollection extends Collection +class SuggestedCorrectionCollection extends PayPalApiCollection { - protected function getExpectedClass(): string + public static function getExpectedClass(): string { return SuggestedCorrection::class; } diff --git a/src/AgentCommerce/Struct/V1/ResolutionOption.php b/src/AgentCommerce/Struct/V1/ResolutionOption.php index 270192a7a..579618d07 100644 --- a/src/AgentCommerce/Struct/V1/ResolutionOption.php +++ b/src/AgentCommerce/Struct/V1/ResolutionOption.php @@ -9,8 +9,8 @@ use OpenApi\Attributes as OA; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Struct; use Swag\PayPal\AgentCommerce\Struct\V1\Referral\MetaData; +use Swag\PayPal\RestApi\PayPalApiStruct; /** * @experimental @@ -20,7 +20,7 @@ schema: 'paypal_agentic_commerce_v1_resolution_option', required: ['action', 'label'] )] -class ResolutionOption extends Struct +class ResolutionOption extends PayPalApiStruct { public const ACTION__REDIRECT_TO_MERCHANT = 'REDIRECT_TO_MERCHANT'; public const ACTION__MODIFY_CART = 'MODIFY_CART'; @@ -145,9 +145,4 @@ public function jsonSerialize(): array { return \array_filter(parent::jsonSerialize()); } - - public function isset(string $propertyName): bool - { - return isset($this->{$propertyName}); - } } diff --git a/src/AgentCommerce/Struct/V1/ResolutionOptionCollection.php b/src/AgentCommerce/Struct/V1/ResolutionOptionCollection.php index ea7fe817f..f0a9aefcb 100644 --- a/src/AgentCommerce/Struct/V1/ResolutionOptionCollection.php +++ b/src/AgentCommerce/Struct/V1/ResolutionOptionCollection.php @@ -8,17 +8,17 @@ namespace Swag\PayPal\AgentCommerce\Struct\V1; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Collection; +use Swag\PayPal\RestApi\PayPalApiCollection; /** * @experimental * - * @extends Collection + * @extends PayPalApiCollection */ #[Package('checkout')] -class ResolutionOptionCollection extends Collection +class ResolutionOptionCollection extends PayPalApiCollection { - protected function getExpectedClass(): string + public static function getExpectedClass(): string { return ResolutionOption::class; } diff --git a/src/AgentCommerce/Struct/V1/ShippingAddress.php b/src/AgentCommerce/Struct/V1/ShippingAddress.php index ea870d9fb..82749a0b5 100644 --- a/src/AgentCommerce/Struct/V1/ShippingAddress.php +++ b/src/AgentCommerce/Struct/V1/ShippingAddress.php @@ -7,12 +7,17 @@ namespace Swag\PayPal\AgentCommerce\Struct\V1; +use OpenApi\Attributes as OA; use Shopware\Core\Framework\Log\Package; /** * @experimental */ #[Package('checkout')] +#[OA\Schema( + schema: 'paypal_agentic_commerce_v1_shipping_address', + required: ['countryCode'] +)] class ShippingAddress extends Address { } diff --git a/src/AgentCommerce/Struct/V1/ShippingOption.php b/src/AgentCommerce/Struct/V1/ShippingOption.php index 23cc2ecd1..af7bd6dcf 100644 --- a/src/AgentCommerce/Struct/V1/ShippingOption.php +++ b/src/AgentCommerce/Struct/V1/ShippingOption.php @@ -9,7 +9,7 @@ use OpenApi\Attributes as OA; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Struct; +use Swag\PayPal\RestApi\PayPalApiStruct; /** * @experimental @@ -19,7 +19,7 @@ schema: 'paypal_agentic_commerce_v1_shipping_option', required: ['price', 'isSelected'] )] -class ShippingOption extends Struct +class ShippingOption extends PayPalApiStruct { /** * Unique shipping option identifier diff --git a/src/AgentCommerce/Struct/V1/ShippingOptionCollection.php b/src/AgentCommerce/Struct/V1/ShippingOptionCollection.php index cd0fbcedd..fbd9f9e93 100644 --- a/src/AgentCommerce/Struct/V1/ShippingOptionCollection.php +++ b/src/AgentCommerce/Struct/V1/ShippingOptionCollection.php @@ -8,17 +8,17 @@ namespace Swag\PayPal\AgentCommerce\Struct\V1; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Collection; +use Swag\PayPal\RestApi\PayPalApiCollection; /** * @experimental * - * @extends Collection + * @extends PayPalApiCollection */ #[Package('checkout')] -class ShippingOptionCollection extends Collection +class ShippingOptionCollection extends PayPalApiCollection { - protected function getExpectedClass(): string + public static function getExpectedClass(): string { return ShippingOption::class; } diff --git a/src/AgentCommerce/Struct/V1/ValidationIssue.php b/src/AgentCommerce/Struct/V1/ValidationIssue.php index ad9da2e7d..f6f639319 100644 --- a/src/AgentCommerce/Struct/V1/ValidationIssue.php +++ b/src/AgentCommerce/Struct/V1/ValidationIssue.php @@ -9,7 +9,6 @@ use OpenApi\Attributes as OA; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Struct; use Swag\PayPal\AgentCommerce\Struct\V1\Context\AbstractContext; use Swag\PayPal\AgentCommerce\Struct\V1\Context\BusinessRuleErrorContext; use Swag\PayPal\AgentCommerce\Struct\V1\Context\DataErrorContext; @@ -17,6 +16,7 @@ use Swag\PayPal\AgentCommerce\Struct\V1\Context\PaymentErrorContext; use Swag\PayPal\AgentCommerce\Struct\V1\Context\PricingErrorContext; use Swag\PayPal\AgentCommerce\Struct\V1\Context\ShippingErrorContext; +use Swag\PayPal\RestApi\PayPalApiStruct; /** * @experimental @@ -26,7 +26,7 @@ schema: 'paypal_agentic_commerce_v1_validation_issue', required: ['code', 'type', 'message'] )] -class ValidationIssue extends Struct +class ValidationIssue extends PayPalApiStruct { public const CODE__INVENTORY_ISSUE = 'INVENTORY_ISSUE'; public const CODE__PRICING_ERROR = 'PRICING_ERROR'; @@ -108,7 +108,7 @@ enum: self::TYPES /** * Available actions to resolve this issue */ - #[OA\Property(ref: ResolutionOptionCollection::class)] + #[OA\Property(type: 'array', items: new OA\Items(ref: ResolutionOption::class))] protected ResolutionOptionCollection $resolutionOptions; public function getCode(): string @@ -203,9 +203,4 @@ public function jsonSerialize(): array { return \array_filter(parent::jsonSerialize()); } - - public function isset(string $propertyName): bool - { - return isset($this->{$propertyName}); - } } diff --git a/src/AgentCommerce/Struct/V1/ValidationIssueCollection.php b/src/AgentCommerce/Struct/V1/ValidationIssueCollection.php index ee2d3878e..1aeefd5d7 100644 --- a/src/AgentCommerce/Struct/V1/ValidationIssueCollection.php +++ b/src/AgentCommerce/Struct/V1/ValidationIssueCollection.php @@ -8,17 +8,17 @@ namespace Swag\PayPal\AgentCommerce\Struct\V1; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Collection; +use Swag\PayPal\RestApi\PayPalApiCollection; /** * @experimental * - * @extends Collection + * @extends PayPalApiCollection */ #[Package('checkout')] -class ValidationIssueCollection extends Collection +class ValidationIssueCollection extends PayPalApiCollection { - protected function getExpectedClass(): string + public static function getExpectedClass(): string { return ValidationIssue::class; } diff --git a/src/AgentCommerce/Struct/V1/Value/AllergyInformationValue.php b/src/AgentCommerce/Struct/V1/Value/AllergyInformationValue.php index 42663953f..11d9754d8 100644 --- a/src/AgentCommerce/Struct/V1/Value/AllergyInformationValue.php +++ b/src/AgentCommerce/Struct/V1/Value/AllergyInformationValue.php @@ -9,14 +9,14 @@ use OpenApi\Attributes as OA; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Struct; +use Swag\PayPal\RestApi\PayPalApiStruct; /** * @experimental */ #[Package('checkout')] #[OA\Schema(schema: 'paypal_agentic_commerce_v1_value_allergy_information_value')] -class AllergyInformationValue extends Struct +class AllergyInformationValue extends PayPalApiStruct { public const SEVERITY__MILD = 'mild'; public const SEVERITY__MODERATE = 'moderate'; diff --git a/src/AgentCommerce/Struct/V1/Value/CustomEngravingTextValue.php b/src/AgentCommerce/Struct/V1/Value/CustomEngravingTextValue.php index 44516ac68..1f5685199 100644 --- a/src/AgentCommerce/Struct/V1/Value/CustomEngravingTextValue.php +++ b/src/AgentCommerce/Struct/V1/Value/CustomEngravingTextValue.php @@ -9,7 +9,7 @@ use OpenApi\Attributes as OA; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Struct; +use Swag\PayPal\RestApi\PayPalApiStruct; /** * @experimental @@ -19,7 +19,7 @@ schema: 'paypal_agentic_commerce_v1_value_custom_engraving_text_value', required: ['text'] )] -class CustomEngravingTextValue extends Struct +class CustomEngravingTextValue extends PayPalApiStruct { public const FONT__ARIAL = 'arial'; public const FONT__TIMES = 'times'; diff --git a/src/AgentCommerce/Struct/V1/Value/CustomSizingInfoValue.php b/src/AgentCommerce/Struct/V1/Value/CustomSizingInfoValue.php index 21de99b04..3fffa9519 100644 --- a/src/AgentCommerce/Struct/V1/Value/CustomSizingInfoValue.php +++ b/src/AgentCommerce/Struct/V1/Value/CustomSizingInfoValue.php @@ -9,15 +9,15 @@ use OpenApi\Attributes as OA; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Struct; use Swag\PayPal\AgentCommerce\Struct\V1\Referral\Measurements; +use Swag\PayPal\RestApi\PayPalApiStruct; /** * @experimental */ #[Package('checkout')] #[OA\Schema(schema: 'paypal_agentic_commerce_v1_value_custom_sizing_info_value')] -class CustomSizingInfoValue extends Struct +class CustomSizingInfoValue extends PayPalApiStruct { public const SIZE__TIGHT = 'tight'; public const SIZE__REGULAR = 'regular'; diff --git a/src/AgentCommerce/Struct/V1/Value/DeliveryDatePreferenceValue.php b/src/AgentCommerce/Struct/V1/Value/DeliveryDatePreferenceValue.php index 03a1c88c8..ac5b861a5 100644 --- a/src/AgentCommerce/Struct/V1/Value/DeliveryDatePreferenceValue.php +++ b/src/AgentCommerce/Struct/V1/Value/DeliveryDatePreferenceValue.php @@ -9,14 +9,14 @@ use OpenApi\Attributes as OA; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Struct; +use Swag\PayPal\RestApi\PayPalApiStruct; /** * @experimental */ #[Package('checkout')] #[OA\Schema(schema: 'paypal_agentic_commerce_v1_value_delivery_date_preference_value')] -class DeliveryDatePreferenceValue extends Struct +class DeliveryDatePreferenceValue extends PayPalApiStruct { public const TIME_WINDOW__MORNING = 'morning'; public const TIME_WINDOW__AFTERNOON = 'afternoon'; diff --git a/src/AgentCommerce/Struct/V1/Value/DeliveryInstructionsValue.php b/src/AgentCommerce/Struct/V1/Value/DeliveryInstructionsValue.php index d9d4a9106..22012f322 100644 --- a/src/AgentCommerce/Struct/V1/Value/DeliveryInstructionsValue.php +++ b/src/AgentCommerce/Struct/V1/Value/DeliveryInstructionsValue.php @@ -9,7 +9,7 @@ use OpenApi\Attributes as OA; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Struct; +use Swag\PayPal\RestApi\PayPalApiStruct; /** * @experimental @@ -19,7 +19,7 @@ schema: 'paypal_agentic_commerce_v1_value_delivery_instructions_value', required: ['instructions'] )] -class DeliveryInstructionsValue extends Struct +class DeliveryInstructionsValue extends PayPalApiStruct { /** * Special delivery instructions diff --git a/src/AgentCommerce/Struct/V1/Value/GiftMessageValue.php b/src/AgentCommerce/Struct/V1/Value/GiftMessageValue.php index aee83992d..162cd06f1 100644 --- a/src/AgentCommerce/Struct/V1/Value/GiftMessageValue.php +++ b/src/AgentCommerce/Struct/V1/Value/GiftMessageValue.php @@ -9,7 +9,7 @@ use OpenApi\Attributes as OA; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Struct; +use Swag\PayPal\RestApi\PayPalApiStruct; /** * @experimental @@ -19,7 +19,7 @@ schema: 'paypal_agentic_commerce_v1_value_gift_message_value', required: ['message'] )] -class GiftMessageValue extends Struct +class GiftMessageValue extends PayPalApiStruct { /** * Personal message for the recipient diff --git a/src/AgentCommerce/Struct/V1/Value/GiftRecipientEmailValue.php b/src/AgentCommerce/Struct/V1/Value/GiftRecipientEmailValue.php index 19df08c1a..1d05c4249 100644 --- a/src/AgentCommerce/Struct/V1/Value/GiftRecipientEmailValue.php +++ b/src/AgentCommerce/Struct/V1/Value/GiftRecipientEmailValue.php @@ -9,7 +9,7 @@ use OpenApi\Attributes as OA; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Struct; +use Swag\PayPal\RestApi\PayPalApiStruct; /** * @experimental @@ -19,7 +19,7 @@ schema: 'paypal_agentic_commerce_v1_value_gift_recipient_email_value', required: ['email'] )] -class GiftRecipientEmailValue extends Struct +class GiftRecipientEmailValue extends PayPalApiStruct { /** * Recipient's email address diff --git a/src/AgentCommerce/Struct/V1/Value/GiftRecipientNameValue.php b/src/AgentCommerce/Struct/V1/Value/GiftRecipientNameValue.php index ac8051ff9..1349a993c 100644 --- a/src/AgentCommerce/Struct/V1/Value/GiftRecipientNameValue.php +++ b/src/AgentCommerce/Struct/V1/Value/GiftRecipientNameValue.php @@ -9,7 +9,7 @@ use OpenApi\Attributes as OA; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Struct; +use Swag\PayPal\RestApi\PayPalApiStruct; /** * @experimental @@ -19,7 +19,7 @@ schema: 'paypal_agentic_commerce_v1_value_gift_recipient_name_value', required: ['name'] )] -class GiftRecipientNameValue extends Struct +class GiftRecipientNameValue extends PayPalApiStruct { /** * Recipient's full name diff --git a/src/AgentCommerce/Struct/V1/Value/PrivacyConsentValue.php b/src/AgentCommerce/Struct/V1/Value/PrivacyConsentValue.php index e949b8fdf..b549ec1ce 100644 --- a/src/AgentCommerce/Struct/V1/Value/PrivacyConsentValue.php +++ b/src/AgentCommerce/Struct/V1/Value/PrivacyConsentValue.php @@ -9,7 +9,7 @@ use OpenApi\Attributes as OA; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Struct; +use Swag\PayPal\RestApi\PayPalApiStruct; /** * @experimental @@ -19,7 +19,7 @@ schema: 'paypal_agentic_commerce_v1_value_privacy_consent_value', required: ['consented'] )] -class PrivacyConsentValue extends Struct +class PrivacyConsentValue extends PayPalApiStruct { public const CONSENT_TYPE__DATA_PROCESSING = 'data_processing'; public const CONSENT_TYPE__MARKETING = 'marketing'; diff --git a/src/AgentCommerce/Struct/V1/Value/TermsAcceptanceValue.php b/src/AgentCommerce/Struct/V1/Value/TermsAcceptanceValue.php index abbafe40e..d14af2705 100644 --- a/src/AgentCommerce/Struct/V1/Value/TermsAcceptanceValue.php +++ b/src/AgentCommerce/Struct/V1/Value/TermsAcceptanceValue.php @@ -9,7 +9,7 @@ use OpenApi\Attributes as OA; use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Struct\Struct; +use Swag\PayPal\RestApi\PayPalApiStruct; /** * @experimental @@ -19,7 +19,7 @@ schema: 'paypal_agentic_commerce_v1_value_terms_acceptance_value', required: ['accepted', 'termsVersions'] )] -class TermsAcceptanceValue extends Struct +class TermsAcceptanceValue extends PayPalApiStruct { /** * Whether terms were accepted diff --git a/src/AgentCommerce/Subscriber/WebhookSubscriber.php b/src/AgentCommerce/Subscriber/WebhookSubscriber.php index 791beec83..f1205a9f2 100644 --- a/src/AgentCommerce/Subscriber/WebhookSubscriber.php +++ b/src/AgentCommerce/Subscriber/WebhookSubscriber.php @@ -36,7 +36,7 @@ class WebhookSubscriber implements EventSubscriberInterface public function __construct( private readonly EntityRepository $salesChannelRepository, private readonly HoneyWebhookService $webhookService, - private readonly EntityRepository $notificationRepository, // @phpstan-ignore parameter.deprecatedClass, property.deprecatedClass + private readonly EntityRepository $notificationRepository, ) { } @@ -115,6 +115,7 @@ public function handleWebhookLifecycle(EntityWrittenEvent $event): void ]; } + // @phpstan-ignore method.deprecated $context->scope(Context::SYSTEM_SCOPE, function (Context $context) use ($notification): void { $this->notificationRepository->create([$notification], $context); }); diff --git a/src/AgentCommerce/Util/PayPalCartFactory.php b/src/AgentCommerce/Util/PayPalCartFactory.php index 5f025938a..ab6ff478e 100644 --- a/src/AgentCommerce/Util/PayPalCartFactory.php +++ b/src/AgentCommerce/Util/PayPalCartFactory.php @@ -9,10 +9,10 @@ use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Uuid\Uuid; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\Address; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\CartItemCollection; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\PayPalCart; use Swag\PayPal\AgentCommerce\Exception\AgentException; +use Swag\PayPal\AgentCommerce\Struct\V1\Address; +use Swag\PayPal\AgentCommerce\Struct\V1\CartItemCollection; +use Swag\PayPal\AgentCommerce\Struct\V1\PayPalCart; /** * @internal diff --git a/src/AgentCommerce/Util/PayPalCartTransformer.php b/src/AgentCommerce/Util/PayPalCartTransformer.php index 84fa4cf83..e8f9b1faa 100644 --- a/src/AgentCommerce/Util/PayPalCartTransformer.php +++ b/src/AgentCommerce/Util/PayPalCartTransformer.php @@ -15,6 +15,7 @@ use Shopware\Core\Checkout\Customer\CustomerEntity; use Shopware\Core\Checkout\Shipping\SalesChannel\AbstractShippingMethodRoute; use Shopware\Core\Content\Product\ProductCollection; +use Shopware\Core\Content\Product\ProductEntity; use Shopware\Core\Framework\Context; use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository; use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria; @@ -24,24 +25,26 @@ use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Uuid\Uuid; use Shopware\Core\System\Country\CountryCollection; +use Shopware\Core\System\Locale\LocaleCollection; +use Shopware\Core\System\Locale\LocaleEntity; use Shopware\Core\System\SalesChannel\SalesChannelContext; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\Address; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\AppliedCoupon; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\AppliedCouponCollection; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\BillingAddress; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\CartItem; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\CartItemCollection; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\CartTotals; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\Customer; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\Money; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\PayPalCart; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\Phone; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\Referral\CustomerName; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\ShippingAddress; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\ShippingOption; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\ShippingOptionCollection; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\ValidationIssueCollection; use Swag\PayPal\AgentCommerce\Exception\AgentException; +use Swag\PayPal\AgentCommerce\Struct\V1\Address; +use Swag\PayPal\AgentCommerce\Struct\V1\AppliedCoupon; +use Swag\PayPal\AgentCommerce\Struct\V1\AppliedCouponCollection; +use Swag\PayPal\AgentCommerce\Struct\V1\BillingAddress; +use Swag\PayPal\AgentCommerce\Struct\V1\CartItem; +use Swag\PayPal\AgentCommerce\Struct\V1\CartItemCollection; +use Swag\PayPal\AgentCommerce\Struct\V1\CartTotals; +use Swag\PayPal\AgentCommerce\Struct\V1\Customer; +use Swag\PayPal\AgentCommerce\Struct\V1\Money; +use Swag\PayPal\AgentCommerce\Struct\V1\PayPalCart; +use Swag\PayPal\AgentCommerce\Struct\V1\Phone; +use Swag\PayPal\AgentCommerce\Struct\V1\Referral\CustomerName; +use Swag\PayPal\AgentCommerce\Struct\V1\ShippingAddress; +use Swag\PayPal\AgentCommerce\Struct\V1\ShippingOption; +use Swag\PayPal\AgentCommerce\Struct\V1\ShippingOptionCollection; +use Swag\PayPal\AgentCommerce\Struct\V1\ValidationIssueCollection; use Swag\PayPal\AgentCommerce\Validation\ValidationIssues; use Symfony\Component\HttpFoundation\Request; @@ -54,12 +57,14 @@ class PayPalCartTransformer /** * @param EntityRepository $productRepository * @param EntityRepository $countryRepository + * @param EntityRepository $countryRepository */ public function __construct( private readonly EntityRepository $productRepository, private readonly EntityRepository $countryRepository, private readonly AbstractShippingMethodRoute $shippingMethodRoute, private readonly ValidationIssues $validationIssues, + private readonly EntityRepository $localeRepository, ) { } @@ -108,7 +113,7 @@ public function convertToCartItems(array $lineItems, SalesChannelContext $contex // itemId will be removed in the future. $cartItem->setItemId($lineItem->getReferencedId()); $cartItem->setVariantId($lineItem->getReferencedId()); - $cartItem->setParentId($lineItem->getPayloadValue('parentId')); // @phpstan-ignore method.deprecated + $cartItem->setParentId($lineItem->getPayloadValue('parentId')); $cartItem->setQuantity($lineItem->getQuantity()); $cartItem->setName($lineItem->getLabel()); $cartItem->setPrice($itemPrice); @@ -175,7 +180,22 @@ public function convertToValidationIssues(Cart $cart, CartItemCollection $cartIt continue; } - $errors->add($this->validationIssues->cartError($error, $context->getLanguageInfo()->localeCode)); + // @phpstan-ignore function.alreadyNarrowedType + if (\method_exists($context, 'getLanguageInfo')) { + $languageInfo = $context->getLanguageInfo(); // @phpstan-ignore method.deprecated + $localeCode = $languageInfo?->localeCode; + } else { + $localeCriteria = new Criteria(); + $localeCriteria->addFilter(new EqualsFilter('languages.id', $context->getLanguageId())); + + /** @var LocaleEntity|null $locale */ + $locale = $this->localeRepository->search($localeCriteria, $context->getContext())->first(); + $localeCode = $locale?->getCode(); + } + + if ($localeCode) { + $errors->add($this->validationIssues->cartError($error, $localeCode)); + } } $lineItems = $cart->getLineItems()->filterFlatByType(LineItem::PRODUCT_LINE_ITEM_TYPE); @@ -194,6 +214,8 @@ public function convertToValidationIssues(Cart $cart, CartItemCollection $cartIt new RangeFilter('stock', [RangeFilter::LTE => 0]), new NotFilter('AND', [new EqualsFilter('restockTime', null)]) ); + + /** @var ProductEntity[] $restockProducts */ $restockProducts = $this->productRepository->search($criteria, $context->getContext())->getElements(); } @@ -203,7 +225,7 @@ public function convertToValidationIssues(Cart $cart, CartItemCollection $cartIt } foreach ($lineItems as $lineItem) { - $stock = $lineItem->getPayloadValue('stock'); // @phpstan-ignore method.deprecated + $stock = $lineItem->getPayloadValue('stock'); if ($stock !== null && $stock < $lineItem->getQuantity()) { $issue = $this->validationIssues->outOfStock($lineItem, $restockProducts[$lineItem->getReferencedId()] ?? null, $context->getCurrency()); @@ -267,7 +289,7 @@ public function convertAddress(?CustomerAddressEntity $addressEntity, string $cl $criteria = new Criteria([$addressEntity->getCountryId()]); $criteria->addFields(['iso']); - $iso = $this->countryRepository->search($criteria, $context)->first()?->get('iso'); + $iso = $this->countryRepository->search($criteria, $context)->first()?->get('iso'); // @phpstan-ignore method.deprecated if (!$iso) { throw AgentException::requiredFieldInvalid('address.countryCode', 'Country not found'); } @@ -334,7 +356,7 @@ public function convertToAppliedCoupons(array $lineItems, SalesChannelContext $c $discount->setCurrencyCode($context->getCurrency()->getIsoCode()); $coupon = new AppliedCoupon(); - $coupon->setCode($lineItem->getPayloadValue('code')); // @phpstan-ignore method.deprecated + $coupon->setCode($lineItem->getPayloadValue('code')); $coupon->setDescription($lineItem->getDescription()); $coupon->setDiscountAmount($discount); diff --git a/src/AgentCommerce/Util/ShopwareCartTransformer.php b/src/AgentCommerce/Util/ShopwareCartTransformer.php index 1e93534bd..5539a0c03 100644 --- a/src/AgentCommerce/Util/ShopwareCartTransformer.php +++ b/src/AgentCommerce/Util/ShopwareCartTransformer.php @@ -21,15 +21,15 @@ use Shopware\Core\System\SalesChannel\SalesChannelContext; use Shopware\Core\System\Salutation\SalutationCollection; use Shopware\Core\System\Salutation\SalutationDefinition; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\Address; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\Coupon; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\CouponCollection; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\Customer; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\PayPalCart; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\Phone; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\Referral\CustomerName; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\ShippingAddress; use Swag\PayPal\AgentCommerce\Exception\AgentException; +use Swag\PayPal\AgentCommerce\Struct\V1\Address; +use Swag\PayPal\AgentCommerce\Struct\V1\Coupon; +use Swag\PayPal\AgentCommerce\Struct\V1\CouponCollection; +use Swag\PayPal\AgentCommerce\Struct\V1\Customer; +use Swag\PayPal\AgentCommerce\Struct\V1\PayPalCart; +use Swag\PayPal\AgentCommerce\Struct\V1\Phone; +use Swag\PayPal\AgentCommerce\Struct\V1\Referral\CustomerName; +use Swag\PayPal\AgentCommerce\Struct\V1\ShippingAddress; /** * @internal diff --git a/src/AgentCommerce/Validation/ValidationIssues.php b/src/AgentCommerce/Validation/ValidationIssues.php index d63a7830f..e2c3fbb14 100644 --- a/src/AgentCommerce/Validation/ValidationIssues.php +++ b/src/AgentCommerce/Validation/ValidationIssues.php @@ -23,12 +23,12 @@ use Shopware\Core\Framework\DataAbstractionLayer\Pricing\CashRoundingConfig; use Shopware\Core\Framework\Log\Package; use Shopware\Core\System\Currency\CurrencyEntity; -use Shopware\PayPalSDK\Builder\AgenticCommerce\V1\ValidationIssueBuilder; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\Context\InventoryIssueContext; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\Context\PricingErrorContext; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\Referral\MetaData; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\ResolutionOption; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\ValidationIssue; +use Swag\PayPal\AgentCommerce\Struct\V1\Builder\ValidationIssueBuilder; +use Swag\PayPal\AgentCommerce\Struct\V1\Context\InventoryIssueContext; +use Swag\PayPal\AgentCommerce\Struct\V1\Context\PricingErrorContext; +use Swag\PayPal\AgentCommerce\Struct\V1\Referral\MetaData; +use Swag\PayPal\AgentCommerce\Struct\V1\ResolutionOption; +use Swag\PayPal\AgentCommerce\Struct\V1\ValidationIssue; #[Package('checkout')] class ValidationIssues @@ -43,9 +43,10 @@ public function __construct( public function outOfStock(LineItem $item, ?ProductEntity $restockProduct, CurrencyEntity $currency): ValidationIssue { - $stock = $item->getPayloadValue('stock'); // @phpstan-ignore method.deprecated + $stock = $item->getPayloadValue('stock'); $builder = new ValidationIssueBuilder(); + // @phpstan-ignore method.resultUnused $builder ->withCode(ValidationIssue::CODE__INVENTORY_ISSUE) ->withType(ValidationIssue::TYPE__BUSINESS_RULE) @@ -135,26 +136,20 @@ public function cartError(Error $error, string $localeCode): ValidationIssue $validationIssue->setType(ValidationIssue::TYPE__BUSINESS_RULE); $validationIssue->setCode(ValidationIssue::CODE__BUSINESS_RULE_ERROR); - /** @deprecated tag:v11.0.0 - "getTranslatedMessage" is added with v6.7.3.0 */ - // @phpstan-ignore function.alreadyNarrowedType - if (\method_exists($error, 'getTranslatedMessage')) { - $validationIssue->setUserMessage($error->getTranslatedMessage()); - } else { - $parameters = []; - foreach ($error->getParameters() as $key => $value) { - $parameters['%' . $key . '%'] = $value; - } - - $message = $this->translator->trans( - 'checkout.' . $error->getMessageKey(), - $parameters, - null, - $localeCode - ); - - $validationIssue->setUserMessage($message); + $parameters = []; + foreach ($error->getParameters() as $key => $value) { + $parameters['%' . $key . '%'] = $value; } + $message = $this->translator->trans( + 'checkout.' . $error->getMessageKey(), + $parameters, + null, + $localeCode + ); + + $validationIssue->setUserMessage($message); + switch ($error::class) { case ProductNotFoundError::class: case PurchaseStepsError::class: diff --git a/src/DevOps/Command/GenerateOpenApi.php b/src/DevOps/Command/GenerateOpenApi.php index d190ad1f4..fadbd093d 100644 --- a/src/DevOps/Command/GenerateOpenApi.php +++ b/src/DevOps/Command/GenerateOpenApi.php @@ -75,10 +75,7 @@ protected function generateStoreApiSchema(SymfonyStyle $style, Generator $genera $openApi = $generator->generate([ Util::finder(self::ROOT_DIR . '/src/RestApi'), Util::finder(self::ROOT_DIR . '/src/Checkout'), - Util::finder( - self::ROOT_DIR . '/../../../vendor/shopware/paypal-sdk/src/Struct', - [self::ROOT_DIR . '/../../../vendor/shopware/paypal-sdk/src/Struct/AgenticCommerce'], - ), + Util::finder(self::ROOT_DIR . '/src/AgentCommerce/Struct'), ])?->toJson(); if ($openApi === null) { @@ -111,11 +108,6 @@ protected function generateAdminApiSchema(SymfonyStyle $style, Generator $genera Util::finder(self::ROOT_DIR . '/src/Setting'), Util::finder(self::ROOT_DIR . '/src/Webhook'), Util::finder(self::ROOT_DIR . '/src/AgentCommerce'), - Util::finder( - self::ROOT_DIR . '/../../../vendor/shopware/paypal-sdk/src/Struct', - [self::ROOT_DIR . '/../../../vendor/shopware/paypal-sdk/src/Struct/AgenticCommerce'], - ), - Util::finder(__DIR__ . '/Polyfill'), ])?->toJson(); if ($openApi === null) { diff --git a/src/Resources/Schema/AdminApi/openapi.json b/src/Resources/Schema/AdminApi/openapi.json index 6291527b5..a79cef20b 100644 --- a/src/Resources/Schema/AdminApi/openapi.json +++ b/src/Resources/Schema/AdminApi/openapi.json @@ -7016,6 +7016,1831 @@ } }, "type": "object" + }, + "paypal_agentic_commerce_v1_address": { + "required": [ + "countryCode" + ], + "properties": { + "address_line1": { + "description": "The first line of the address, such as number and street, for example, 173 Drury Lane.\nNeeded for data entry, and Compliance and Risk checks. This field needs to pass the full address.", + "type": "string", + "maxLength": 300, + "minLength": 0 + }, + "address_line2": { + "type": "string", + "maxLength": 300, + "minLength": 0 + }, + "admin_area1": { + "description": "The highest-level sub-division in a country, which is usually a province, state, or ISO-3166-2 subdivision.\nThis data is formatted for postal delivery, for example, CA and not California. Value, by country, is UK.\nA county. US. A state. Canada. A province. Japan. A prefecture. Switzerland. A kanton.", + "type": "string", + "maxLength": 300, + "minLength": 0 + }, + "admin_area2": { + "description": "A city, town, or village. Smaller than admin_area_level_1.", + "type": "string", + "maxLength": 120, + "minLength": 0 + }, + "postal_code": { + "description": "The postal code, which is the ZIP code or equivalent.\nTypically required for countries with a postal code or an equivalent. See postal code.", + "type": "string", + "maxLength": 60, + "minLength": 0 + }, + "country_code": { + "description": "The 2-character ISO 3166-1 alpha-2 country code", + "type": "string", + "maxLength": 2, + "minLength": 2, + "pattern": "^[A-Z]{2}$" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_agent_error_detail": {}, + "paypal_agentic_commerce_v1_applied_coupon": { + "required": [ + "code", + "description", + "discount_amount" + ], + "properties": { + "code": { + "type": "string" + }, + "description": { + "type": "string" + }, + "discount_amount": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_money" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_billing_address": { + "description": "Billing address for merchant business purposes, obtained from customer's PayPal profile. Similar to shipping addresses, billing addresses can be retrieved from customer's default address information stored in their PayPal account.\n\nWhen Billing Address is Available:\n\nCustomer has a default billing address in their PayPal profile\nPayPal Credit and Buy Now Pay Later transactions\nGuest checkout with credit/debit cards\nUser explicitly consents to address sharing\nRequired for tax compliance and regulatory reporting\n\nPrimary Use Cases:\n\nTax calculation: Sales tax/VAT rates determined by billing jurisdiction\nExport compliance: Product restrictions based on customer's billing country\nFinancial reporting: Accounting systems requiring customer billing location\nAddress verification: Comparing billing vs shipping addresses for fraud prevention\n\nSecondary Use Cases:\n\nBusiness intelligence: Customer demographics and market analysis\nB2B invoicing: Legal invoices requiring customer billing details\nCompliance reporting: Regulatory requirements based on customer location\n\nNote: Payment verification (AVS) and chargeback protection are handled by PayPal internally.\n\nImplementation Notes:\n\nBilling address is typically available from customer profile data\nCan be populated during cart creation if customer provides it\nFalls back to shipping address when billing address is not specified\nMerchants should handle graceful fallback scenarios", + "required": [ + "countryCode" + ], + "allOf": [ + { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_address" + } + ] + }, + "paypal_agentic_commerce_v1_cart_item": { + "required": [ + "itemId", + "quantity", + "price" + ], + "properties": { + "item_id": { + "description": "Unique product identifier (optional in v1 for backwards compatibility)", + "type": "string" + }, + "variant_id": { + "description": "Product variant identifier (color, size, etc.) - unique id of the product", + "type": "string" + }, + "parent_id": { + "description": "Item grouping identifier - passed when item is part of a group in honey catalog", + "type": "string" + }, + "quantity": { + "description": "Number of items", + "type": "integer", + "minimum": 1 + }, + "name": { + "description": "Product display name", + "type": "string" + }, + "description": { + "description": "Product description", + "type": "string" + }, + "item_url": { + "description": "URL for product details page", + "type": "string" + }, + "price": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_money" + }, + "selected_attributes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_referral_selected_attribute" + } + }, + "gift_options": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_gift_options" + }, + "custom_options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_referral_custom_option" + } + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_cart_totals": { + "required": [ + "total" + ], + "properties": { + "subtotal": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_money" + }, + "discount": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_money" + }, + "shipping": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_money" + }, + "tax": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_money" + }, + "handling": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_money" + }, + "insurance": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_money" + }, + "shipping_discount": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_money" + }, + "custom_charges": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_money" + }, + "total": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_money" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_checkout_field": { + "required": [ + "type", + "status" + ], + "properties": { + "type": { + "description": "PayPal-approved checkout field type", + "type": "string", + "enum": [ + "AGE_VERIFICATION_18_PLUS", + "AGE_VERIFICATION_21_PLUS", + "GIFT_RECIPIENT_EMAIL", + "GIFT_RECIPIENT_NAME", + "GIFT_MESSAGE", + "DELIVERY_INSTRUCTIONS", + "DELIVERY_DATE_PREFERENCE", + "ALLERGY_INFORMATION", + "CUSTOM_ENGRAVING_TEXT", + "CUSTOM_SIZING_INFO", + "TERMS_ACCEPTANCE", + "PRIVACY_CONSENT" + ] + }, + "status": { + "description": "Field completion and validation status:\n\nPENDING: Field needs customer input\n\nInitial state when field is required\nAI agent should collect this information\nvalue field is null or empty\n\nCOMPLETED: Valid value provided and accepted\n\nCustomer provided acceptable input\nValue passes all validation rules\nCart can proceed with this field resolved\n\nREJECTED: Invalid or unacceptable value provided\n\nCustomer provided input that doesn't meet requirements\nvalidation_issue explains the specific problem\nAI agent should request corrected input\n\nERROR: System error during processing\n\nTechnical failure in field processing\nShould retry or escalate to support\nNot caused by customer input", + "type": "string", + "enum": [ + "PENDING", + "COMPLETED", + "REJECTED", + "ERROR" + ] + }, + "value": { + "description": "Structured value based on field type. Each checkout field type has a specific value schema.\nUse oneOf to validate against the appropriate structure for the field type.", + "oneOf": [ + { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_value_age_verification_value" + }, + { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_value_gift_recipient_email_value" + }, + { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_value_gift_recipient_name_value" + }, + { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_value_gift_message_value" + }, + { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_value_delivery_instructions_value" + }, + { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_value_delivery_date_preference_value" + }, + { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_value_allergy_information_value" + }, + { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_value_custom_engraving_text_value" + }, + { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_value_custom_sizing_info_value" + }, + { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_value_terms_acceptance_value" + }, + { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_value_privacy_consent_value" + } + ] + }, + "context": { + "description": "Additional context and metadata for the checkout field.\nThis is a flexible object that can contain any field-specific information needed for validation, display, or processing.\nThe structure varies based on the field type.", + "type": "mixed" + }, + "validation_issue": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_validation_issue" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_context_business_rule_error_context": { + "required": [ + "specific_issue", + "current_amount", + "required_amount", + "maximum_amount", + "remaining_amount", + "account_status", + "suspension_reason", + "suspension_date", + "monthly_limit", + "current_month_total", + "reset_date", + "total_quantity", + "approval_threshold", + "maintenance_end_time", + "service_status", + "retry_after", + "contact_info", + "restricted_items", + "age_requirement", + "business_hours", + "shortage_amount", + "exceeds_by" + ], + "properties": { + "specific_issue": { + "description": "Specific business rule issue type", + "type": "string" + }, + "current_amount": { + "description": "Current order amount", + "type": "string" + }, + "required_amount": { + "description": "Required minimum amount", + "type": "string" + }, + "maximum_amount": { + "description": "Maximum allowed amount", + "type": "string" + }, + "remaining_amount": { + "description": "Amount needed to meet minimum", + "type": "string" + }, + "account_status": { + "description": "Customer account status", + "type": "string" + }, + "suspension_reason": { + "description": "Reason for account suspension", + "type": "string" + }, + "suspension_date": { + "description": "Date of account suspension", + "type": "string" + }, + "monthly_limit": { + "description": "Monthly purchase limit", + "type": "string" + }, + "current_month_total": { + "description": "Current month purchase total", + "type": "string" + }, + "reset_date": { + "description": "When limits reset", + "type": "string" + }, + "total_quantity": { + "description": "Total quantity in bulk order", + "type": "integer" + }, + "approval_threshold": { + "description": "Quantity requiring approval", + "type": "integer" + }, + "maintenance_end_time": { + "description": "When maintenance ends", + "type": "string" + }, + "service_status": { + "description": "Current service status", + "type": "string" + }, + "retry_after": { + "description": "Seconds before retry recommended", + "type": "integer" + }, + "contact_info": { + "description": "Support contact information", + "type": "string" + }, + "restricted_items": { + "description": "Items with restrictions", + "type": "array", + "items": { + "type": "string" + } + }, + "age_requirement": { + "description": "Required minimum age", + "type": "integer" + }, + "business_hours": { + "description": "Store business hours", + "type": "array", + "items": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_referral_business_hour" + } + }, + "shortage_amount": { + "description": "Amount needed to meet minimum requirements", + "type": "string" + }, + "exceeds_by": { + "description": "Amount by which limit is exceeded", + "type": "string" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_context_data_error_context": { + "required": [ + "specific_issue", + "field_name", + "provided_value", + "expected_format", + "max_length", + "min_length", + "current_length", + "regex_pattern", + "suggested_value", + "allowed_values", + "required_fields", + "field_descriptions" + ], + "properties": { + "specific_issue": { + "description": "Specific business rule issue type", + "type": "string" + }, + "field_name": { + "description": "Name of the field with validation error", + "type": "string" + }, + "provided_value": { + "description": "Value that failed validation", + "type": "string" + }, + "expected_format": { + "description": "Expected format description", + "type": "string" + }, + "max_length": { + "description": "Maximum allowed length", + "type": "integer" + }, + "min_length": { + "description": "Minimum required length", + "type": "integer" + }, + "current_length": { + "description": "Current value length", + "type": "integer" + }, + "regex_pattern": { + "description": "Required regex pattern", + "type": "string" + }, + "suggested_value": { + "description": "Suggested corrected value", + "type": "string" + }, + "allowed_values": { + "description": "List of allowed values for enum fields", + "type": "array", + "items": { + "type": "string" + } + }, + "required_fields": { + "description": "List of required field names", + "type": "array", + "items": { + "type": "string" + } + }, + "field_descriptions": { + "description": "Descriptions for required fields", + "type": "array", + "items": { + "type": "string" + } + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_context_inventory_issue_context": { + "required": [ + "specific_issue", + "item_id", + "variant_id", + "available_quantity", + "requested_quantity", + "reserved_quantity", + "restock_date", + "estimated_ship_date", + "back_order_limit", + "current_back_orders", + "discontinuation_date", + "suggested_alternatives", + "upgrade_available", + "seasonal_start_date", + "last_sold" + ], + "properties": { + "specific_issue": { + "description": "Specific business rule issue type", + "type": "string" + }, + "item_id": { + "description": "Product item identifier", + "type": "string" + }, + "variant_id": { + "description": "Product variant identifier if applicable", + "type": "string" + }, + "available_quantity": { + "description": "Currently available quantity", + "type": "integer", + "minimum": 0 + }, + "requested_quantity": { + "type": "integer", + "minimum": 1 + }, + "reserved_quantity": { + "description": "Quantity reserved for other transactions", + "type": "integer", + "minimum": 0 + }, + "restock_date": { + "description": "Expected restock date", + "type": "string" + }, + "estimated_ship_date": { + "description": "Estimated shipping date for back-orders", + "type": "string" + }, + "back_order_limit": { + "description": "Maximum allowed back-order quantity", + "type": "integer" + }, + "current_back_orders": { + "type": "integer" + }, + "discontinuation_date": { + "type": "string" + }, + "suggested_alternatives": { + "description": "Alternative product IDs", + "type": "array", + "items": { + "type": "string" + } + }, + "upgrade_available": { + "description": "Whether newer version is available", + "type": "boolean" + }, + "seasonal_start_date": { + "description": "When seasonal product becomes available", + "type": "string" + }, + "last_sold": { + "description": "When item was last sold", + "type": "string" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_context_payment_error_context": { + "required": [ + "specific_issue", + "order_total", + "payment_limit", + "minimum_amount", + "excess_amount", + "payment_method", + "currency_code", + "from_currency", + "to_currency", + "conversion_service", + "supported_payment_methods", + "processor_error_code", + "decline_reason", + "payment_token" + ], + "properties": { + "specific_issue": { + "description": "Specific business rule issue type", + "type": "string" + }, + "order_total": { + "description": "Total order amount", + "type": "string" + }, + "payment_limit": { + "description": "Maximum payment limit", + "type": "string" + }, + "minimum_amount": { + "description": "Minimum payment amount", + "type": "string" + }, + "excess_amount": { + "description": "Amount exceeding limit", + "type": "string" + }, + "payment_method": { + "description": "Payment method being used", + "type": "string" + }, + "currency_code": { + "description": "Transaction currency", + "type": "string" + }, + "from_currency": { + "description": "Source currency for conversion", + "type": "string" + }, + "to_currency": { + "description": "Target currency for conversion", + "type": "string" + }, + "conversion_service": { + "description": "Currency conversion service status", + "type": "string" + }, + "supported_payment_methods": { + "description": "List of supported payment methods", + "type": "array", + "items": { + "type": "string" + } + }, + "processor_error_code": { + "description": "Payment processor specific error code", + "type": "string" + }, + "decline_reason": { + "description": "Reason for payment decline", + "type": "string" + }, + "payment_token": { + "description": "Payment token that was declined", + "type": "string" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_context_pricing_error_context": { + "required": [ + "specific_issue", + "item_id", + "original_price", + "current_price", + "currency_code", + "price_change_reason", + "price_increase", + "price_decrease", + "coupon_code", + "usage_limit", + "current_usage", + "expiration_date", + "minimum_order_amount", + "supported_currencies", + "found_currencies", + "tax_service_error", + "current_date", + "discount_amount", + "required_currency_consistency", + "mixed_items" + ], + "properties": { + "specific_issue": { + "description": "Specific business rule issue type", + "type": "string" + }, + "item_id": { + "description": "Item with pricing issue", + "type": "string" + }, + "original_price": { + "description": "Original price value", + "type": "string" + }, + "current_price": { + "description": "Current price value", + "type": "string" + }, + "currency_code": { + "description": "Currency code", + "type": "string" + }, + "price_change_reason": { + "description": "Reason for price change", + "type": "string", + "enum": [ + "promotional_ended", + "promotional_started", + "market_adjustment", + "cost_increase", + "seasonal_pricing", + "component_cost_increase", + "terms_updated" + ] + }, + "price_increase": { + "description": "Amount of price increase", + "type": "string" + }, + "price_decrease": { + "description": "Amount of price decrease", + "type": "string" + }, + "coupon_code": { + "description": "Coupon code with issues", + "type": "string" + }, + "usage_limit": { + "description": "Coupon usage limit", + "type": "integer" + }, + "current_usage": { + "description": "Current coupon usage count", + "type": "integer" + }, + "expiration_date": { + "description": "Discount expiration date", + "type": "string" + }, + "minimum_order_amount": { + "description": "Minimum order for discount", + "type": "string" + }, + "supported_currencies": { + "description": "List of supported currencies", + "type": "array", + "items": { + "type": "string" + } + }, + "found_currencies": { + "description": "Multiple currencies found in cart", + "type": "array", + "items": { + "type": "string" + } + }, + "tax_service_error": { + "description": "Tax calculation service error", + "type": "string" + }, + "current_date": { + "description": "Current system date for comparisons", + "type": "string" + }, + "discount_amount": { + "description": "Discount amount that was applied", + "type": "string" + }, + "required_currency_consistency": { + "description": "Whether all items must use same currency", + "type": "boolean" + }, + "mixed_items": { + "description": "Items with different currencies", + "type": "array", + "items": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_referral_mixed_item" + } + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_context_shipping_error_context": { + "required": [ + "specific_issue", + "validation_failures", + "suggested_corrections", + "address_quality_score", + "restricted_items", + "restriction_reason", + "po_box_detected", + "destination_country", + "restricted_region", + "supported_countries", + "provided_address" + ], + "properties": { + "specific_issue": { + "description": "Specific business rule issue type", + "type": "string" + }, + "validation_failures": { + "description": "Specific address validation failures", + "type": "array", + "items": { + "type": "string" + } + }, + "suggested_corrections": { + "description": "Suggested address corrections", + "type": "array", + "items": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_referral_suggested_correction" + } + }, + "address_quality_score": { + "description": "Address validation quality score", + "type": "number", + "format": "float", + "maximum": 1, + "minimum": 0 + }, + "restricted_items": { + "description": "Items with shipping restrictions", + "type": "array", + "items": { + "type": "string" + } + }, + "restriction_reason": { + "description": "Reason for shipping restriction", + "type": "string", + "enum": [ + "signature_required", + "age_verification_required", + "export_controlled", + "hazardous_material", + "oversized_item", + "po_box_restriction" + ] + }, + "po_box_detected": { + "description": "Whether PO Box was detected", + "type": "boolean" + }, + "destination_country": { + "description": "Destination country code", + "type": "string" + }, + "restricted_region": { + "description": "Restricted region identifier", + "type": "string" + }, + "supported_countries": { + "description": "List of supported countries", + "type": "array", + "items": { + "type": "string" + } + }, + "provided_address": { + "description": "Address string that failed validation", + "type": "string" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_coupon": { + "required": [ + "code", + "action" + ], + "properties": { + "code": { + "description": "Coupon code identifier", + "type": "string" + }, + "action": { + "description": "Action to perform on this specific coupon", + "type": "string", + "enum": [ + "APPLY", + "REMOVE" + ] + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_customer": { + "required": [ + "name", + "phone", + "email_address" + ], + "properties": { + "name": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_referral_customer_name" + }, + "phone": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_phone" + }, + "email_address": { + "description": "The internationalized email address.\nNote: Up to 64 characters are allowed before and 255 characters are allowed after the @ sign.\nHowever, the generally accepted maximum length for an email address is 254 characters.\nThe pattern verifies that an unquoted @ sign exists.", + "type": "string", + "maxLength": 254, + "minLength": 3, + "pattern": "^(?:[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)*|\"(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21\\x23-\\x5b\\x5d-\\x7f]|\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])*\")@(?:(?:[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\\.)+[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?|\\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[A-Za-z0-9-]*[A-Za-z0-9]:(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21-\\x5a\\x53-\\x7f]|\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])+)\\])$" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_error": { + "required": [ + "name", + "message" + ], + "properties": { + "name": { + "description": "Error name/type", + "type": "string" + }, + "message": { + "description": "Error description", + "type": "string" + }, + "debug_id": { + "description": "Unique error identifier for support", + "type": "string" + }, + "details": { + "description": "Detailed error information", + "type": "array", + "items": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_agent_error_detail" + } + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_geo_coordinates": { + "required": [ + "latitude", + "longitude", + "subdivision", + "country_code" + ], + "properties": { + "latitude": { + "description": "Latitude coordinate in decimal degrees (-90 to 90). WGS84 datum.", + "type": "string", + "pattern": "^-?([1-8]?[0-9](\\.\\d+)?|90(\\.0+)?)$" + }, + "longitude": { + "description": "Longitude coordinate in decimal degrees (-180 to 180). WGS84 datum.", + "type": "string", + "pattern": "^-?((1[0-7]|[1-9])?[0-9](\\.\\d+)?|180(\\.0+)?)$" + }, + "subdivision": { + "description": "Administrative subdivision code (state, province, region).\nISO 3166-2 format without country prefix (e.g., 'CA' for California, 'ON' for Ontario).", + "type": "string", + "maxLength": 10, + "minLength": 1, + "pattern": "^[A-Z0-9-]+$" + }, + "country_code": { + "description": "ISO 3166-1 alpha-2 country code for the coordinate location.", + "type": "string", + "maxLength": 2, + "minLength": 2, + "pattern": "^[A-Z]{2}$" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_gift_options": { + "required": [ + "is_gift", + "recipient", + "delivery_date", + "sender_name", + "gift_message", + "gift_wrap" + ], + "properties": { + "is_gift": { + "description": "Whether this is a gift", + "type": "boolean" + }, + "recipient": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_referral_recipient" + }, + "delivery_date": { + "description": "Scheduled delivery date in RFC3339 format. Seconds are required while fractional seconds are optional.\n\nexample: 2024-12-25T09:00:00Z", + "type": "string", + "maxLength": 64, + "minLength": 20, + "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])[T,t]([0-1][0-9]|2[0-3]):[0-5][0-9]:([0-5][0-9]|60)([.][0-9]+)?([Zz]|[+-][0-9]{2}:[0-9]{2})$" + }, + "sender_name": { + "description": "Name of gift sender", + "type": "string" + }, + "gift_message": { + "description": "Personal message (max 500 characters)", + "type": "string", + "maxLength": 500 + }, + "gift_wrap": { + "description": "Whether to include gift wrapping", + "type": "boolean" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_link": { + "required": [ + "rel", + "href" + ], + "properties": { + "rel": { + "description": "Link relationship type", + "type": "string", + "enum": [ + "rel", + "update", + "checkout" + ] + }, + "href": { + "description": "Target URL for the link\n\nexample: https://your-domain.com/api/paypal/v1/merchant-cart/CART-123", + "type": "string" + }, + "method": { + "description": "HTTP method for the link", + "type": "string", + "enum": [ + "GET", + "POST", + "PUT" + ] + }, + "title": { + "description": "Human-readable description of the link", + "type": "string" + }, + "type": { + "description": "Expected content type", + "type": "string" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_money": { + "required": [ + "currencyCode", + "value" + ], + "properties": { + "currency_code": { + "description": "The 3-character ISO-4217 currency code that identifies the currency.", + "type": "string", + "maxLength": 3, + "minLength": 3, + "pattern": "^[\\S\\s]*$" + }, + "value": { + "description": "The value, which might be: An integer for currencies like JPY that are not typically fractional. A decimal fraction for currencies like TND that are subdivided into thousandths. For the required number of decimal places for a currency code, see Currency Codes.", + "type": "string", + "maxLength": 0, + "minLength": 32, + "pattern": "^((-?[0-9]+)|(-?([0-9]+)?[.][0-9]+))$" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_pay_pal_cart": { + "required": [ + "items", + "paymentMethod" + ], + "properties": { + "id": { + "type": "string", + "readOnly": true + }, + "status": { + "type": "string", + "enum": [ + "CREATED", + "COMPLETE", + "READY", + "INCOMPLETE" + ], + "readOnly": true + }, + "validation_status": { + "type": "string", + "enum": [ + "VALID", + "INVALID", + "REQUIRES_ADDITIONAL_INFORMATION" + ], + "readOnly": true + }, + "validation_issues": { + "description": "List of issues preventing checkout (empty = ready)", + "type": "array", + "items": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_validation_issue" + } + }, + "totals": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_cart_totals" + }, + "applied_coupons": { + "description": "Successfully applied coupons (server-calculated)", + "type": "array", + "items": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_applied_coupon" + } + }, + "available_shipping_options": { + "description": "Available shipping methods with selection state", + "type": "array", + "items": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_shipping_option" + } + }, + "links": { + "description": "HATEOAS navigation links for cart operations", + "type": "array", + "items": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_link" + } + }, + "items": { + "description": "Products in the cart", + "type": "array", + "items": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_cart_item" + } + }, + "customer": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_customer" + }, + "shipping_address": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_shipping_address" + }, + "billing_address": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_billing_address" + }, + "payment_method": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_payment_method" + }, + "checkout_fields": { + "description": "Custom checkout fields (age verification, etc.)", + "type": "array", + "items": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_checkout_field" + } + }, + "coupons": { + "description": "Discount coupons to apply or remove from cart", + "type": "array", + "items": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_coupon" + } + }, + "geo_coordinates": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_geo_coordinates" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_payment_method": { + "required": [ + "type" + ], + "properties": { + "type": { + "description": "Payment method type - only PayPal is supported by this API", + "type": "string", + "enum": [ + "paypal" + ] + }, + "token": { + "description": "PayPal payment token from cart creation or customer approval", + "type": "string" + }, + "payer_id": { + "description": "PayPal payer identifier provided after customer approval", + "type": "string" + }, + "approval_url": { + "description": "URL used to inform merchant that the PayPal buyer approved the order", + "type": "string" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_phone": { + "required": [ + "countryCode", + "nationalNumber" + ], + "properties": { + "country_code": { + "description": "The country calling code (CC), in its canonical international E.164 numbering plan format.\nThe combined length of the CC and the national number must not be greater than 15 digits.\nThe national number consists of a national destination code (NDC) and subscriber number (SN)", + "type": "string", + "maxLength": 3, + "minLength": 1, + "pattern": "^[0-9]{1,3}?$" + }, + "national_number": { + "description": "The national number, in its canonical international E.164 numbering plan format.\nThe combined length of the country calling code (CC) and the national number must not be greater than 15 digits.\nThe national number consists of a national destination code (NDC) and subscriber number (SN).", + "type": "string", + "maxLength": 14, + "minLength": 1, + "pattern": "^[0-9]{1,14}?$" + }, + "extension_number": { + "description": "The extension number", + "type": "string", + "maxLength": 15, + "minLength": 1, + "pattern": "^[0-9]{1,15}?$" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_referral_business_hour": { + "required": [ + "open_time", + "close_time", + "timezone" + ], + "properties": { + "open_time": { + "type": "string" + }, + "close_time": { + "type": "string" + }, + "timezone": { + "type": "string" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_referral_custom_option": { + "required": [ + "name", + "value", + "price_modifier" + ], + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + }, + "price_modifier": { + "type": "string" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_referral_customer_name": { + "required": [ + "given_name", + "surname" + ], + "properties": { + "given_name": { + "type": "string" + }, + "surname": { + "type": "string" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_referral_measurements": { + "required": [ + "chest", + "waist", + "height", + "weight" + ], + "properties": { + "chest": { + "type": "string" + }, + "waist": { + "type": "string" + }, + "height": { + "type": "string" + }, + "weight": { + "type": "string" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_referral_meta_data": { + "required": [ + "cost_impact", + "priority", + "waist", + "auto_applicable", + "estimated_time", + "redirect_required" + ], + "properties": { + "cost_impact": { + "type": "string" + }, + "priority": { + "type": "string" + }, + "waist": { + "type": "string" + }, + "auto_applicable": { + "type": "boolean" + }, + "estimated_time": { + "type": "string" + }, + "redirect_required": { + "type": "boolean" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_referral_mixed_item": { + "required": [ + "item_id", + "currency" + ], + "properties": { + "item_id": { + "type": "string" + }, + "currency": { + "type": "string" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_referral_recipient": { + "required": [ + "name", + "email", + "phone" + ], + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "phone": { + "type": "string" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_referral_selected_attribute": { + "required": [ + "name", + "value" + ], + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_referral_suggested_correction": { + "required": [ + "postal_code", + "address_line1", + "admin_area2" + ], + "properties": { + "postal_code": { + "type": "string" + }, + "address_line1": { + "type": "string" + }, + "admin_area2": { + "type": "string" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_resolution_option": { + "required": [ + "action", + "label" + ], + "properties": { + "action": { + "description": "Machine-readable action identifier", + "type": "string", + "enum": [ + "REDIRECT_TO_MERCHANT", + "MODIFY_CART", + "ACCEPT_NEW_PRICE", + "ACCEPT_BACK_ORDER", + "SUGGEST_ALTERNATIVE", + "REMOVE_ITEM", + "UPDATE_ADDRESS", + "PROVIDE_MISSING_FIELD", + "USE_DIFFERENT_PAYMENT", + "SPLIT_ORDER", + "CONTACT_SUPPORT", + "RETRY_LATER", + "REQUEST_APPROVAL", + "WAIT_FOR_RESTOCK", + "USE_DIFFERENT_CURRENCY", + "ACCEPT_PRE_ORDER", + "UPDATE_SHIPPING_METHOD", + "ACCEPT_TERMS", + "VERIFY_ACCOUNT", + "APPLY_DIFFERENT_COUPON", + "REMOVE_COUPON", + "CHOOSE_DIFFERENT_VARIANT" + ] + }, + "label": { + "description": "Human-readable action label", + "type": "string" + }, + "url": { + "description": "URL to redirect to for resolution", + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_referral_meta_data" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_shipping_address": { + "required": [ + "countryCode" + ], + "allOf": [ + { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_address" + } + ] + }, + "paypal_agentic_commerce_v1_shipping_option": { + "required": [ + "price", + "isSelected" + ], + "properties": { + "id": { + "description": "Unique shipping option identifier", + "type": "string" + }, + "name": { + "description": "Display name", + "type": "string" + }, + "description": { + "description": "Detailed description", + "type": "string" + }, + "price": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_money" + }, + "is_selected": { + "description": "Whether this shipping option is currently selected", + "type": "boolean" + }, + "estimated_delivery": { + "description": "Estimated delivery date in YYYY-MM-DD format", + "type": "string", + "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])$" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_validation_issue": { + "required": [ + "code", + "type", + "message" + ], + "properties": { + "code": { + "description": "Consolidated error category", + "type": "string", + "enum": [ + "INVENTORY_ISSUE", + "PRICING_ERROR", + "SHIPPING_ERROR", + "PAYMENT_ERROR", + "DATA_ERROR", + "BUSINESS_RULE_ERROR" + ] + }, + "type": { + "description": "Type classification for error handling", + "type": "string", + "enum": [ + "MISSING_FIELD", + "INVALID_DATA", + "BUSINESS_RULE" + ] + }, + "message": { + "description": "Technical message for developers and logging", + "type": "string" + }, + "user_message": { + "description": "Customer-friendly message for end users", + "type": "string" + }, + "item_id": { + "description": "Specific item ID if the issue is item-specific", + "type": "string" + }, + "field": { + "description": "Specific field name if the issue is field-specific", + "type": "string" + }, + "context": { + "description": "Category-specific context information", + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_context_inventory_issue_context" + }, + { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_context_pricing_error_context" + }, + { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_context_shipping_error_context" + }, + { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_context_payment_error_context" + }, + { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_context_data_error_context" + }, + { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_context_business_rule_error_context" + } + ] + }, + "resolution_options": { + "description": "Available actions to resolve this issue", + "type": "array", + "items": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_resolution_option" + } + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_value_age_verification_value": { + "required": [ + "confirmed" + ], + "properties": { + "confirmed": { + "description": "Whether age verification was confirmed", + "type": "boolean" + }, + "verificationMethod": { + "description": "Method used for age verification", + "type": "string", + "enum": [ + "self_declaration", + "id_verification", + "third_party" + ] + }, + "verificationDate": { + "description": "When verification was completed", + "type": "string" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_value_allergy_information_value": { + "required": [ + "allergies", + "severity", + "medications", + "emergency_contact" + ], + "properties": { + "allergies": { + "description": "List of known allergies", + "type": "array", + "items": { + "type": "string" + } + }, + "severity": { + "description": "Allergy severity level", + "type": "string", + "enum": [ + "life_threatening", + "mild", + "moderate", + "severe" + ] + }, + "medications": { + "description": "Medications to avoid", + "type": "array", + "items": { + "type": "string" + } + }, + "emergency_contact": { + "description": "Emergency contact information\n\nexample: +1-555-999-8888", + "type": "string" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_value_custom_engraving_text_value": { + "required": [ + "text" + ], + "properties": { + "text": { + "description": "Text to be engraved", + "type": "string", + "maxLength": 100 + }, + "font": { + "description": "Preferred font style", + "type": "string", + "enum": [ + "arial", + "times", + "script", + "block" + ] + }, + "size": { + "description": "Text size preference", + "type": "string", + "enum": [ + "small", + "medium", + "large" + ] + }, + "position": { + "description": "Engraving position", + "type": "string", + "enum": [ + "front", + "back", + "side", + "bottom" + ] + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_value_custom_sizing_info_value": { + "required": [ + "measurements", + "size_preference", + "special_requirements" + ], + "properties": { + "measurements": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_referral_measurements" + }, + "size_preference": { + "description": "Fit preference", + "type": "string", + "enum": [ + "tight", + "regular", + "loose" + ] + }, + "special_requirements": { + "description": "Special sizing requirements", + "type": "string" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_value_delivery_date_preference_value": { + "required": [ + "preferred_date", + "time_window", + "specific_time" + ], + "properties": { + "preferred_date": { + "description": "Preferred delivery date", + "type": "string" + }, + "time_window": { + "description": "Preferred time window", + "type": "string", + "enum": [ + "morning", + "afternoon", + "evening", + "anytime" + ] + }, + "specific_time": { + "description": "Specific preferred time (HH:MM format)", + "type": "string", + "pattern": "^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_value_delivery_instructions_value": { + "required": [ + "instructions" + ], + "properties": { + "instructions": { + "description": "Special delivery instructions", + "type": "string", + "maxLength": 200 + }, + "access_code": { + "description": "Building or gate access code", + "type": "string" + }, + "contact_phone": { + "description": "Contact phone for delivery", + "type": "string" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_value_gift_message_value": { + "required": [ + "message" + ], + "properties": { + "message": { + "description": "Personal message for the recipient", + "type": "string", + "maxLength": 500 + }, + "sender_name": { + "description": "Name of the person sending the gift", + "type": "string" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_value_gift_recipient_email_value": { + "required": [ + "email" + ], + "properties": { + "email": { + "description": "Recipient's email address", + "type": "string" + }, + "verified": { + "description": "Whether email was verified", + "type": "boolean" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_value_gift_recipient_name_value": { + "required": [ + "name" + ], + "properties": { + "name": { + "description": "Recipient's full name", + "type": "string" + }, + "first_name": { + "description": "Recipient's first name", + "type": "string" + }, + "last_name": { + "description": "Recipient's last name", + "type": "string" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_value_privacy_consent_value": { + "required": [ + "consented" + ], + "properties": { + "consented": { + "description": "Whether privacy policy was consented to", + "type": "boolean" + }, + "consent_types": { + "description": "Types of consent given", + "type": "array", + "items": { + "type": "string" + }, + "enum": [ + "analytics", + "third_party_sharing", + "data_processing", + "marketing" + ] + }, + "policy_version": { + "description": "Privacy policy version", + "type": "string" + }, + "consent_date": { + "description": "When consent was given", + "type": "string" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_value_terms_acceptance_value": { + "required": [ + "accepted", + "termsVersions" + ], + "properties": { + "accepted": { + "description": "Whether terms were accepted", + "type": "boolean" + }, + "terms_versions": { + "description": "Version of terms accepted", + "type": "string" + }, + "acceptance_date": { + "description": "When terms were accepted", + "type": "string" + }, + "ip_address": { + "description": "IP address of acceptance", + "type": "string" + } + }, + "type": "object" } } }, diff --git a/src/Resources/Schema/StoreApi/openapi.json b/src/Resources/Schema/StoreApi/openapi.json index 8d10ef3f5..bd64cc2c4 100644 --- a/src/Resources/Schema/StoreApi/openapi.json +++ b/src/Resources/Schema/StoreApi/openapi.json @@ -5404,6 +5404,1831 @@ } }, "type": "object" + }, + "paypal_agentic_commerce_v1_address": { + "required": [ + "countryCode" + ], + "properties": { + "address_line1": { + "description": "The first line of the address, such as number and street, for example, 173 Drury Lane.\nNeeded for data entry, and Compliance and Risk checks. This field needs to pass the full address.", + "type": "string", + "maxLength": 300, + "minLength": 0 + }, + "address_line2": { + "type": "string", + "maxLength": 300, + "minLength": 0 + }, + "admin_area1": { + "description": "The highest-level sub-division in a country, which is usually a province, state, or ISO-3166-2 subdivision.\nThis data is formatted for postal delivery, for example, CA and not California. Value, by country, is UK.\nA county. US. A state. Canada. A province. Japan. A prefecture. Switzerland. A kanton.", + "type": "string", + "maxLength": 300, + "minLength": 0 + }, + "admin_area2": { + "description": "A city, town, or village. Smaller than admin_area_level_1.", + "type": "string", + "maxLength": 120, + "minLength": 0 + }, + "postal_code": { + "description": "The postal code, which is the ZIP code or equivalent.\nTypically required for countries with a postal code or an equivalent. See postal code.", + "type": "string", + "maxLength": 60, + "minLength": 0 + }, + "country_code": { + "description": "The 2-character ISO 3166-1 alpha-2 country code", + "type": "string", + "maxLength": 2, + "minLength": 2, + "pattern": "^[A-Z]{2}$" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_agent_error_detail": {}, + "paypal_agentic_commerce_v1_applied_coupon": { + "required": [ + "code", + "description", + "discount_amount" + ], + "properties": { + "code": { + "type": "string" + }, + "description": { + "type": "string" + }, + "discount_amount": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_money" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_billing_address": { + "description": "Billing address for merchant business purposes, obtained from customer's PayPal profile. Similar to shipping addresses, billing addresses can be retrieved from customer's default address information stored in their PayPal account.\n\nWhen Billing Address is Available:\n\nCustomer has a default billing address in their PayPal profile\nPayPal Credit and Buy Now Pay Later transactions\nGuest checkout with credit/debit cards\nUser explicitly consents to address sharing\nRequired for tax compliance and regulatory reporting\n\nPrimary Use Cases:\n\nTax calculation: Sales tax/VAT rates determined by billing jurisdiction\nExport compliance: Product restrictions based on customer's billing country\nFinancial reporting: Accounting systems requiring customer billing location\nAddress verification: Comparing billing vs shipping addresses for fraud prevention\n\nSecondary Use Cases:\n\nBusiness intelligence: Customer demographics and market analysis\nB2B invoicing: Legal invoices requiring customer billing details\nCompliance reporting: Regulatory requirements based on customer location\n\nNote: Payment verification (AVS) and chargeback protection are handled by PayPal internally.\n\nImplementation Notes:\n\nBilling address is typically available from customer profile data\nCan be populated during cart creation if customer provides it\nFalls back to shipping address when billing address is not specified\nMerchants should handle graceful fallback scenarios", + "required": [ + "countryCode" + ], + "allOf": [ + { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_address" + } + ] + }, + "paypal_agentic_commerce_v1_cart_item": { + "required": [ + "itemId", + "quantity", + "price" + ], + "properties": { + "item_id": { + "description": "Unique product identifier (optional in v1 for backwards compatibility)", + "type": "string" + }, + "variant_id": { + "description": "Product variant identifier (color, size, etc.) - unique id of the product", + "type": "string" + }, + "parent_id": { + "description": "Item grouping identifier - passed when item is part of a group in honey catalog", + "type": "string" + }, + "quantity": { + "description": "Number of items", + "type": "integer", + "minimum": 1 + }, + "name": { + "description": "Product display name", + "type": "string" + }, + "description": { + "description": "Product description", + "type": "string" + }, + "item_url": { + "description": "URL for product details page", + "type": "string" + }, + "price": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_money" + }, + "selected_attributes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_referral_selected_attribute" + } + }, + "gift_options": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_gift_options" + }, + "custom_options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_referral_custom_option" + } + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_cart_totals": { + "required": [ + "total" + ], + "properties": { + "subtotal": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_money" + }, + "discount": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_money" + }, + "shipping": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_money" + }, + "tax": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_money" + }, + "handling": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_money" + }, + "insurance": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_money" + }, + "shipping_discount": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_money" + }, + "custom_charges": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_money" + }, + "total": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_money" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_checkout_field": { + "required": [ + "type", + "status" + ], + "properties": { + "type": { + "description": "PayPal-approved checkout field type", + "type": "string", + "enum": [ + "AGE_VERIFICATION_18_PLUS", + "AGE_VERIFICATION_21_PLUS", + "GIFT_RECIPIENT_EMAIL", + "GIFT_RECIPIENT_NAME", + "GIFT_MESSAGE", + "DELIVERY_INSTRUCTIONS", + "DELIVERY_DATE_PREFERENCE", + "ALLERGY_INFORMATION", + "CUSTOM_ENGRAVING_TEXT", + "CUSTOM_SIZING_INFO", + "TERMS_ACCEPTANCE", + "PRIVACY_CONSENT" + ] + }, + "status": { + "description": "Field completion and validation status:\n\nPENDING: Field needs customer input\n\nInitial state when field is required\nAI agent should collect this information\nvalue field is null or empty\n\nCOMPLETED: Valid value provided and accepted\n\nCustomer provided acceptable input\nValue passes all validation rules\nCart can proceed with this field resolved\n\nREJECTED: Invalid or unacceptable value provided\n\nCustomer provided input that doesn't meet requirements\nvalidation_issue explains the specific problem\nAI agent should request corrected input\n\nERROR: System error during processing\n\nTechnical failure in field processing\nShould retry or escalate to support\nNot caused by customer input", + "type": "string", + "enum": [ + "PENDING", + "COMPLETED", + "REJECTED", + "ERROR" + ] + }, + "value": { + "description": "Structured value based on field type. Each checkout field type has a specific value schema.\nUse oneOf to validate against the appropriate structure for the field type.", + "oneOf": [ + { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_value_age_verification_value" + }, + { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_value_gift_recipient_email_value" + }, + { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_value_gift_recipient_name_value" + }, + { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_value_gift_message_value" + }, + { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_value_delivery_instructions_value" + }, + { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_value_delivery_date_preference_value" + }, + { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_value_allergy_information_value" + }, + { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_value_custom_engraving_text_value" + }, + { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_value_custom_sizing_info_value" + }, + { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_value_terms_acceptance_value" + }, + { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_value_privacy_consent_value" + } + ] + }, + "context": { + "description": "Additional context and metadata for the checkout field.\nThis is a flexible object that can contain any field-specific information needed for validation, display, or processing.\nThe structure varies based on the field type.", + "type": "mixed" + }, + "validation_issue": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_validation_issue" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_context_business_rule_error_context": { + "required": [ + "specific_issue", + "current_amount", + "required_amount", + "maximum_amount", + "remaining_amount", + "account_status", + "suspension_reason", + "suspension_date", + "monthly_limit", + "current_month_total", + "reset_date", + "total_quantity", + "approval_threshold", + "maintenance_end_time", + "service_status", + "retry_after", + "contact_info", + "restricted_items", + "age_requirement", + "business_hours", + "shortage_amount", + "exceeds_by" + ], + "properties": { + "specific_issue": { + "description": "Specific business rule issue type", + "type": "string" + }, + "current_amount": { + "description": "Current order amount", + "type": "string" + }, + "required_amount": { + "description": "Required minimum amount", + "type": "string" + }, + "maximum_amount": { + "description": "Maximum allowed amount", + "type": "string" + }, + "remaining_amount": { + "description": "Amount needed to meet minimum", + "type": "string" + }, + "account_status": { + "description": "Customer account status", + "type": "string" + }, + "suspension_reason": { + "description": "Reason for account suspension", + "type": "string" + }, + "suspension_date": { + "description": "Date of account suspension", + "type": "string" + }, + "monthly_limit": { + "description": "Monthly purchase limit", + "type": "string" + }, + "current_month_total": { + "description": "Current month purchase total", + "type": "string" + }, + "reset_date": { + "description": "When limits reset", + "type": "string" + }, + "total_quantity": { + "description": "Total quantity in bulk order", + "type": "integer" + }, + "approval_threshold": { + "description": "Quantity requiring approval", + "type": "integer" + }, + "maintenance_end_time": { + "description": "When maintenance ends", + "type": "string" + }, + "service_status": { + "description": "Current service status", + "type": "string" + }, + "retry_after": { + "description": "Seconds before retry recommended", + "type": "integer" + }, + "contact_info": { + "description": "Support contact information", + "type": "string" + }, + "restricted_items": { + "description": "Items with restrictions", + "type": "array", + "items": { + "type": "string" + } + }, + "age_requirement": { + "description": "Required minimum age", + "type": "integer" + }, + "business_hours": { + "description": "Store business hours", + "type": "array", + "items": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_referral_business_hour" + } + }, + "shortage_amount": { + "description": "Amount needed to meet minimum requirements", + "type": "string" + }, + "exceeds_by": { + "description": "Amount by which limit is exceeded", + "type": "string" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_context_data_error_context": { + "required": [ + "specific_issue", + "field_name", + "provided_value", + "expected_format", + "max_length", + "min_length", + "current_length", + "regex_pattern", + "suggested_value", + "allowed_values", + "required_fields", + "field_descriptions" + ], + "properties": { + "specific_issue": { + "description": "Specific business rule issue type", + "type": "string" + }, + "field_name": { + "description": "Name of the field with validation error", + "type": "string" + }, + "provided_value": { + "description": "Value that failed validation", + "type": "string" + }, + "expected_format": { + "description": "Expected format description", + "type": "string" + }, + "max_length": { + "description": "Maximum allowed length", + "type": "integer" + }, + "min_length": { + "description": "Minimum required length", + "type": "integer" + }, + "current_length": { + "description": "Current value length", + "type": "integer" + }, + "regex_pattern": { + "description": "Required regex pattern", + "type": "string" + }, + "suggested_value": { + "description": "Suggested corrected value", + "type": "string" + }, + "allowed_values": { + "description": "List of allowed values for enum fields", + "type": "array", + "items": { + "type": "string" + } + }, + "required_fields": { + "description": "List of required field names", + "type": "array", + "items": { + "type": "string" + } + }, + "field_descriptions": { + "description": "Descriptions for required fields", + "type": "array", + "items": { + "type": "string" + } + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_context_inventory_issue_context": { + "required": [ + "specific_issue", + "item_id", + "variant_id", + "available_quantity", + "requested_quantity", + "reserved_quantity", + "restock_date", + "estimated_ship_date", + "back_order_limit", + "current_back_orders", + "discontinuation_date", + "suggested_alternatives", + "upgrade_available", + "seasonal_start_date", + "last_sold" + ], + "properties": { + "specific_issue": { + "description": "Specific business rule issue type", + "type": "string" + }, + "item_id": { + "description": "Product item identifier", + "type": "string" + }, + "variant_id": { + "description": "Product variant identifier if applicable", + "type": "string" + }, + "available_quantity": { + "description": "Currently available quantity", + "type": "integer", + "minimum": 0 + }, + "requested_quantity": { + "type": "integer", + "minimum": 1 + }, + "reserved_quantity": { + "description": "Quantity reserved for other transactions", + "type": "integer", + "minimum": 0 + }, + "restock_date": { + "description": "Expected restock date", + "type": "string" + }, + "estimated_ship_date": { + "description": "Estimated shipping date for back-orders", + "type": "string" + }, + "back_order_limit": { + "description": "Maximum allowed back-order quantity", + "type": "integer" + }, + "current_back_orders": { + "type": "integer" + }, + "discontinuation_date": { + "type": "string" + }, + "suggested_alternatives": { + "description": "Alternative product IDs", + "type": "array", + "items": { + "type": "string" + } + }, + "upgrade_available": { + "description": "Whether newer version is available", + "type": "boolean" + }, + "seasonal_start_date": { + "description": "When seasonal product becomes available", + "type": "string" + }, + "last_sold": { + "description": "When item was last sold", + "type": "string" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_context_payment_error_context": { + "required": [ + "specific_issue", + "order_total", + "payment_limit", + "minimum_amount", + "excess_amount", + "payment_method", + "currency_code", + "from_currency", + "to_currency", + "conversion_service", + "supported_payment_methods", + "processor_error_code", + "decline_reason", + "payment_token" + ], + "properties": { + "specific_issue": { + "description": "Specific business rule issue type", + "type": "string" + }, + "order_total": { + "description": "Total order amount", + "type": "string" + }, + "payment_limit": { + "description": "Maximum payment limit", + "type": "string" + }, + "minimum_amount": { + "description": "Minimum payment amount", + "type": "string" + }, + "excess_amount": { + "description": "Amount exceeding limit", + "type": "string" + }, + "payment_method": { + "description": "Payment method being used", + "type": "string" + }, + "currency_code": { + "description": "Transaction currency", + "type": "string" + }, + "from_currency": { + "description": "Source currency for conversion", + "type": "string" + }, + "to_currency": { + "description": "Target currency for conversion", + "type": "string" + }, + "conversion_service": { + "description": "Currency conversion service status", + "type": "string" + }, + "supported_payment_methods": { + "description": "List of supported payment methods", + "type": "array", + "items": { + "type": "string" + } + }, + "processor_error_code": { + "description": "Payment processor specific error code", + "type": "string" + }, + "decline_reason": { + "description": "Reason for payment decline", + "type": "string" + }, + "payment_token": { + "description": "Payment token that was declined", + "type": "string" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_context_pricing_error_context": { + "required": [ + "specific_issue", + "item_id", + "original_price", + "current_price", + "currency_code", + "price_change_reason", + "price_increase", + "price_decrease", + "coupon_code", + "usage_limit", + "current_usage", + "expiration_date", + "minimum_order_amount", + "supported_currencies", + "found_currencies", + "tax_service_error", + "current_date", + "discount_amount", + "required_currency_consistency", + "mixed_items" + ], + "properties": { + "specific_issue": { + "description": "Specific business rule issue type", + "type": "string" + }, + "item_id": { + "description": "Item with pricing issue", + "type": "string" + }, + "original_price": { + "description": "Original price value", + "type": "string" + }, + "current_price": { + "description": "Current price value", + "type": "string" + }, + "currency_code": { + "description": "Currency code", + "type": "string" + }, + "price_change_reason": { + "description": "Reason for price change", + "type": "string", + "enum": [ + "promotional_ended", + "promotional_started", + "market_adjustment", + "cost_increase", + "seasonal_pricing", + "component_cost_increase", + "terms_updated" + ] + }, + "price_increase": { + "description": "Amount of price increase", + "type": "string" + }, + "price_decrease": { + "description": "Amount of price decrease", + "type": "string" + }, + "coupon_code": { + "description": "Coupon code with issues", + "type": "string" + }, + "usage_limit": { + "description": "Coupon usage limit", + "type": "integer" + }, + "current_usage": { + "description": "Current coupon usage count", + "type": "integer" + }, + "expiration_date": { + "description": "Discount expiration date", + "type": "string" + }, + "minimum_order_amount": { + "description": "Minimum order for discount", + "type": "string" + }, + "supported_currencies": { + "description": "List of supported currencies", + "type": "array", + "items": { + "type": "string" + } + }, + "found_currencies": { + "description": "Multiple currencies found in cart", + "type": "array", + "items": { + "type": "string" + } + }, + "tax_service_error": { + "description": "Tax calculation service error", + "type": "string" + }, + "current_date": { + "description": "Current system date for comparisons", + "type": "string" + }, + "discount_amount": { + "description": "Discount amount that was applied", + "type": "string" + }, + "required_currency_consistency": { + "description": "Whether all items must use same currency", + "type": "boolean" + }, + "mixed_items": { + "description": "Items with different currencies", + "type": "array", + "items": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_referral_mixed_item" + } + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_context_shipping_error_context": { + "required": [ + "specific_issue", + "validation_failures", + "suggested_corrections", + "address_quality_score", + "restricted_items", + "restriction_reason", + "po_box_detected", + "destination_country", + "restricted_region", + "supported_countries", + "provided_address" + ], + "properties": { + "specific_issue": { + "description": "Specific business rule issue type", + "type": "string" + }, + "validation_failures": { + "description": "Specific address validation failures", + "type": "array", + "items": { + "type": "string" + } + }, + "suggested_corrections": { + "description": "Suggested address corrections", + "type": "array", + "items": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_referral_suggested_correction" + } + }, + "address_quality_score": { + "description": "Address validation quality score", + "type": "number", + "format": "float", + "maximum": 1, + "minimum": 0 + }, + "restricted_items": { + "description": "Items with shipping restrictions", + "type": "array", + "items": { + "type": "string" + } + }, + "restriction_reason": { + "description": "Reason for shipping restriction", + "type": "string", + "enum": [ + "signature_required", + "age_verification_required", + "export_controlled", + "hazardous_material", + "oversized_item", + "po_box_restriction" + ] + }, + "po_box_detected": { + "description": "Whether PO Box was detected", + "type": "boolean" + }, + "destination_country": { + "description": "Destination country code", + "type": "string" + }, + "restricted_region": { + "description": "Restricted region identifier", + "type": "string" + }, + "supported_countries": { + "description": "List of supported countries", + "type": "array", + "items": { + "type": "string" + } + }, + "provided_address": { + "description": "Address string that failed validation", + "type": "string" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_coupon": { + "required": [ + "code", + "action" + ], + "properties": { + "code": { + "description": "Coupon code identifier", + "type": "string" + }, + "action": { + "description": "Action to perform on this specific coupon", + "type": "string", + "enum": [ + "APPLY", + "REMOVE" + ] + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_customer": { + "required": [ + "name", + "phone", + "email_address" + ], + "properties": { + "name": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_referral_customer_name" + }, + "phone": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_phone" + }, + "email_address": { + "description": "The internationalized email address.\nNote: Up to 64 characters are allowed before and 255 characters are allowed after the @ sign.\nHowever, the generally accepted maximum length for an email address is 254 characters.\nThe pattern verifies that an unquoted @ sign exists.", + "type": "string", + "maxLength": 254, + "minLength": 3, + "pattern": "^(?:[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)*|\"(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21\\x23-\\x5b\\x5d-\\x7f]|\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])*\")@(?:(?:[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\\.)+[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?|\\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[A-Za-z0-9-]*[A-Za-z0-9]:(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21-\\x5a\\x53-\\x7f]|\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])+)\\])$" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_error": { + "required": [ + "name", + "message" + ], + "properties": { + "name": { + "description": "Error name/type", + "type": "string" + }, + "message": { + "description": "Error description", + "type": "string" + }, + "debug_id": { + "description": "Unique error identifier for support", + "type": "string" + }, + "details": { + "description": "Detailed error information", + "type": "array", + "items": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_agent_error_detail" + } + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_geo_coordinates": { + "required": [ + "latitude", + "longitude", + "subdivision", + "country_code" + ], + "properties": { + "latitude": { + "description": "Latitude coordinate in decimal degrees (-90 to 90). WGS84 datum.", + "type": "string", + "pattern": "^-?([1-8]?[0-9](\\.\\d+)?|90(\\.0+)?)$" + }, + "longitude": { + "description": "Longitude coordinate in decimal degrees (-180 to 180). WGS84 datum.", + "type": "string", + "pattern": "^-?((1[0-7]|[1-9])?[0-9](\\.\\d+)?|180(\\.0+)?)$" + }, + "subdivision": { + "description": "Administrative subdivision code (state, province, region).\nISO 3166-2 format without country prefix (e.g., 'CA' for California, 'ON' for Ontario).", + "type": "string", + "maxLength": 10, + "minLength": 1, + "pattern": "^[A-Z0-9-]+$" + }, + "country_code": { + "description": "ISO 3166-1 alpha-2 country code for the coordinate location.", + "type": "string", + "maxLength": 2, + "minLength": 2, + "pattern": "^[A-Z]{2}$" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_gift_options": { + "required": [ + "is_gift", + "recipient", + "delivery_date", + "sender_name", + "gift_message", + "gift_wrap" + ], + "properties": { + "is_gift": { + "description": "Whether this is a gift", + "type": "boolean" + }, + "recipient": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_referral_recipient" + }, + "delivery_date": { + "description": "Scheduled delivery date in RFC3339 format. Seconds are required while fractional seconds are optional.\n\nexample: 2024-12-25T09:00:00Z", + "type": "string", + "maxLength": 64, + "minLength": 20, + "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])[T,t]([0-1][0-9]|2[0-3]):[0-5][0-9]:([0-5][0-9]|60)([.][0-9]+)?([Zz]|[+-][0-9]{2}:[0-9]{2})$" + }, + "sender_name": { + "description": "Name of gift sender", + "type": "string" + }, + "gift_message": { + "description": "Personal message (max 500 characters)", + "type": "string", + "maxLength": 500 + }, + "gift_wrap": { + "description": "Whether to include gift wrapping", + "type": "boolean" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_link": { + "required": [ + "rel", + "href" + ], + "properties": { + "rel": { + "description": "Link relationship type", + "type": "string", + "enum": [ + "rel", + "update", + "checkout" + ] + }, + "href": { + "description": "Target URL for the link\n\nexample: https://your-domain.com/api/paypal/v1/merchant-cart/CART-123", + "type": "string" + }, + "method": { + "description": "HTTP method for the link", + "type": "string", + "enum": [ + "GET", + "POST", + "PUT" + ] + }, + "title": { + "description": "Human-readable description of the link", + "type": "string" + }, + "type": { + "description": "Expected content type", + "type": "string" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_money": { + "required": [ + "currencyCode", + "value" + ], + "properties": { + "currency_code": { + "description": "The 3-character ISO-4217 currency code that identifies the currency.", + "type": "string", + "maxLength": 3, + "minLength": 3, + "pattern": "^[\\S\\s]*$" + }, + "value": { + "description": "The value, which might be: An integer for currencies like JPY that are not typically fractional. A decimal fraction for currencies like TND that are subdivided into thousandths. For the required number of decimal places for a currency code, see Currency Codes.", + "type": "string", + "maxLength": 0, + "minLength": 32, + "pattern": "^((-?[0-9]+)|(-?([0-9]+)?[.][0-9]+))$" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_pay_pal_cart": { + "required": [ + "items", + "paymentMethod" + ], + "properties": { + "id": { + "type": "string", + "readOnly": true + }, + "status": { + "type": "string", + "enum": [ + "CREATED", + "COMPLETE", + "READY", + "INCOMPLETE" + ], + "readOnly": true + }, + "validation_status": { + "type": "string", + "enum": [ + "VALID", + "INVALID", + "REQUIRES_ADDITIONAL_INFORMATION" + ], + "readOnly": true + }, + "validation_issues": { + "description": "List of issues preventing checkout (empty = ready)", + "type": "array", + "items": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_validation_issue" + } + }, + "totals": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_cart_totals" + }, + "applied_coupons": { + "description": "Successfully applied coupons (server-calculated)", + "type": "array", + "items": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_applied_coupon" + } + }, + "available_shipping_options": { + "description": "Available shipping methods with selection state", + "type": "array", + "items": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_shipping_option" + } + }, + "links": { + "description": "HATEOAS navigation links for cart operations", + "type": "array", + "items": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_link" + } + }, + "items": { + "description": "Products in the cart", + "type": "array", + "items": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_cart_item" + } + }, + "customer": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_customer" + }, + "shipping_address": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_shipping_address" + }, + "billing_address": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_billing_address" + }, + "payment_method": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_payment_method" + }, + "checkout_fields": { + "description": "Custom checkout fields (age verification, etc.)", + "type": "array", + "items": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_checkout_field" + } + }, + "coupons": { + "description": "Discount coupons to apply or remove from cart", + "type": "array", + "items": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_coupon" + } + }, + "geo_coordinates": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_geo_coordinates" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_payment_method": { + "required": [ + "type" + ], + "properties": { + "type": { + "description": "Payment method type - only PayPal is supported by this API", + "type": "string", + "enum": [ + "paypal" + ] + }, + "token": { + "description": "PayPal payment token from cart creation or customer approval", + "type": "string" + }, + "payer_id": { + "description": "PayPal payer identifier provided after customer approval", + "type": "string" + }, + "approval_url": { + "description": "URL used to inform merchant that the PayPal buyer approved the order", + "type": "string" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_phone": { + "required": [ + "countryCode", + "nationalNumber" + ], + "properties": { + "country_code": { + "description": "The country calling code (CC), in its canonical international E.164 numbering plan format.\nThe combined length of the CC and the national number must not be greater than 15 digits.\nThe national number consists of a national destination code (NDC) and subscriber number (SN)", + "type": "string", + "maxLength": 3, + "minLength": 1, + "pattern": "^[0-9]{1,3}?$" + }, + "national_number": { + "description": "The national number, in its canonical international E.164 numbering plan format.\nThe combined length of the country calling code (CC) and the national number must not be greater than 15 digits.\nThe national number consists of a national destination code (NDC) and subscriber number (SN).", + "type": "string", + "maxLength": 14, + "minLength": 1, + "pattern": "^[0-9]{1,14}?$" + }, + "extension_number": { + "description": "The extension number", + "type": "string", + "maxLength": 15, + "minLength": 1, + "pattern": "^[0-9]{1,15}?$" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_referral_business_hour": { + "required": [ + "open_time", + "close_time", + "timezone" + ], + "properties": { + "open_time": { + "type": "string" + }, + "close_time": { + "type": "string" + }, + "timezone": { + "type": "string" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_referral_custom_option": { + "required": [ + "name", + "value", + "price_modifier" + ], + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + }, + "price_modifier": { + "type": "string" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_referral_customer_name": { + "required": [ + "given_name", + "surname" + ], + "properties": { + "given_name": { + "type": "string" + }, + "surname": { + "type": "string" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_referral_measurements": { + "required": [ + "chest", + "waist", + "height", + "weight" + ], + "properties": { + "chest": { + "type": "string" + }, + "waist": { + "type": "string" + }, + "height": { + "type": "string" + }, + "weight": { + "type": "string" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_referral_meta_data": { + "required": [ + "cost_impact", + "priority", + "waist", + "auto_applicable", + "estimated_time", + "redirect_required" + ], + "properties": { + "cost_impact": { + "type": "string" + }, + "priority": { + "type": "string" + }, + "waist": { + "type": "string" + }, + "auto_applicable": { + "type": "boolean" + }, + "estimated_time": { + "type": "string" + }, + "redirect_required": { + "type": "boolean" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_referral_mixed_item": { + "required": [ + "item_id", + "currency" + ], + "properties": { + "item_id": { + "type": "string" + }, + "currency": { + "type": "string" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_referral_recipient": { + "required": [ + "name", + "email", + "phone" + ], + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "phone": { + "type": "string" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_referral_selected_attribute": { + "required": [ + "name", + "value" + ], + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_referral_suggested_correction": { + "required": [ + "postal_code", + "address_line1", + "admin_area2" + ], + "properties": { + "postal_code": { + "type": "string" + }, + "address_line1": { + "type": "string" + }, + "admin_area2": { + "type": "string" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_resolution_option": { + "required": [ + "action", + "label" + ], + "properties": { + "action": { + "description": "Machine-readable action identifier", + "type": "string", + "enum": [ + "REDIRECT_TO_MERCHANT", + "MODIFY_CART", + "ACCEPT_NEW_PRICE", + "ACCEPT_BACK_ORDER", + "SUGGEST_ALTERNATIVE", + "REMOVE_ITEM", + "UPDATE_ADDRESS", + "PROVIDE_MISSING_FIELD", + "USE_DIFFERENT_PAYMENT", + "SPLIT_ORDER", + "CONTACT_SUPPORT", + "RETRY_LATER", + "REQUEST_APPROVAL", + "WAIT_FOR_RESTOCK", + "USE_DIFFERENT_CURRENCY", + "ACCEPT_PRE_ORDER", + "UPDATE_SHIPPING_METHOD", + "ACCEPT_TERMS", + "VERIFY_ACCOUNT", + "APPLY_DIFFERENT_COUPON", + "REMOVE_COUPON", + "CHOOSE_DIFFERENT_VARIANT" + ] + }, + "label": { + "description": "Human-readable action label", + "type": "string" + }, + "url": { + "description": "URL to redirect to for resolution", + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_referral_meta_data" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_shipping_address": { + "required": [ + "countryCode" + ], + "allOf": [ + { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_address" + } + ] + }, + "paypal_agentic_commerce_v1_shipping_option": { + "required": [ + "price", + "isSelected" + ], + "properties": { + "id": { + "description": "Unique shipping option identifier", + "type": "string" + }, + "name": { + "description": "Display name", + "type": "string" + }, + "description": { + "description": "Detailed description", + "type": "string" + }, + "price": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_money" + }, + "is_selected": { + "description": "Whether this shipping option is currently selected", + "type": "boolean" + }, + "estimated_delivery": { + "description": "Estimated delivery date in YYYY-MM-DD format", + "type": "string", + "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])$" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_validation_issue": { + "required": [ + "code", + "type", + "message" + ], + "properties": { + "code": { + "description": "Consolidated error category", + "type": "string", + "enum": [ + "INVENTORY_ISSUE", + "PRICING_ERROR", + "SHIPPING_ERROR", + "PAYMENT_ERROR", + "DATA_ERROR", + "BUSINESS_RULE_ERROR" + ] + }, + "type": { + "description": "Type classification for error handling", + "type": "string", + "enum": [ + "MISSING_FIELD", + "INVALID_DATA", + "BUSINESS_RULE" + ] + }, + "message": { + "description": "Technical message for developers and logging", + "type": "string" + }, + "user_message": { + "description": "Customer-friendly message for end users", + "type": "string" + }, + "item_id": { + "description": "Specific item ID if the issue is item-specific", + "type": "string" + }, + "field": { + "description": "Specific field name if the issue is field-specific", + "type": "string" + }, + "context": { + "description": "Category-specific context information", + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_context_inventory_issue_context" + }, + { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_context_pricing_error_context" + }, + { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_context_shipping_error_context" + }, + { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_context_payment_error_context" + }, + { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_context_data_error_context" + }, + { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_context_business_rule_error_context" + } + ] + }, + "resolution_options": { + "description": "Available actions to resolve this issue", + "type": "array", + "items": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_resolution_option" + } + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_value_age_verification_value": { + "required": [ + "confirmed" + ], + "properties": { + "confirmed": { + "description": "Whether age verification was confirmed", + "type": "boolean" + }, + "verificationMethod": { + "description": "Method used for age verification", + "type": "string", + "enum": [ + "self_declaration", + "id_verification", + "third_party" + ] + }, + "verificationDate": { + "description": "When verification was completed", + "type": "string" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_value_allergy_information_value": { + "required": [ + "allergies", + "severity", + "medications", + "emergency_contact" + ], + "properties": { + "allergies": { + "description": "List of known allergies", + "type": "array", + "items": { + "type": "string" + } + }, + "severity": { + "description": "Allergy severity level", + "type": "string", + "enum": [ + "life_threatening", + "mild", + "moderate", + "severe" + ] + }, + "medications": { + "description": "Medications to avoid", + "type": "array", + "items": { + "type": "string" + } + }, + "emergency_contact": { + "description": "Emergency contact information\n\nexample: +1-555-999-8888", + "type": "string" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_value_custom_engraving_text_value": { + "required": [ + "text" + ], + "properties": { + "text": { + "description": "Text to be engraved", + "type": "string", + "maxLength": 100 + }, + "font": { + "description": "Preferred font style", + "type": "string", + "enum": [ + "arial", + "times", + "script", + "block" + ] + }, + "size": { + "description": "Text size preference", + "type": "string", + "enum": [ + "small", + "medium", + "large" + ] + }, + "position": { + "description": "Engraving position", + "type": "string", + "enum": [ + "front", + "back", + "side", + "bottom" + ] + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_value_custom_sizing_info_value": { + "required": [ + "measurements", + "size_preference", + "special_requirements" + ], + "properties": { + "measurements": { + "$ref": "#/components/schemas/paypal_agentic_commerce_v1_referral_measurements" + }, + "size_preference": { + "description": "Fit preference", + "type": "string", + "enum": [ + "tight", + "regular", + "loose" + ] + }, + "special_requirements": { + "description": "Special sizing requirements", + "type": "string" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_value_delivery_date_preference_value": { + "required": [ + "preferred_date", + "time_window", + "specific_time" + ], + "properties": { + "preferred_date": { + "description": "Preferred delivery date", + "type": "string" + }, + "time_window": { + "description": "Preferred time window", + "type": "string", + "enum": [ + "morning", + "afternoon", + "evening", + "anytime" + ] + }, + "specific_time": { + "description": "Specific preferred time (HH:MM format)", + "type": "string", + "pattern": "^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_value_delivery_instructions_value": { + "required": [ + "instructions" + ], + "properties": { + "instructions": { + "description": "Special delivery instructions", + "type": "string", + "maxLength": 200 + }, + "access_code": { + "description": "Building or gate access code", + "type": "string" + }, + "contact_phone": { + "description": "Contact phone for delivery", + "type": "string" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_value_gift_message_value": { + "required": [ + "message" + ], + "properties": { + "message": { + "description": "Personal message for the recipient", + "type": "string", + "maxLength": 500 + }, + "sender_name": { + "description": "Name of the person sending the gift", + "type": "string" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_value_gift_recipient_email_value": { + "required": [ + "email" + ], + "properties": { + "email": { + "description": "Recipient's email address", + "type": "string" + }, + "verified": { + "description": "Whether email was verified", + "type": "boolean" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_value_gift_recipient_name_value": { + "required": [ + "name" + ], + "properties": { + "name": { + "description": "Recipient's full name", + "type": "string" + }, + "first_name": { + "description": "Recipient's first name", + "type": "string" + }, + "last_name": { + "description": "Recipient's last name", + "type": "string" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_value_privacy_consent_value": { + "required": [ + "consented" + ], + "properties": { + "consented": { + "description": "Whether privacy policy was consented to", + "type": "boolean" + }, + "consent_types": { + "description": "Types of consent given", + "type": "array", + "items": { + "type": "string" + }, + "enum": [ + "analytics", + "third_party_sharing", + "data_processing", + "marketing" + ] + }, + "policy_version": { + "description": "Privacy policy version", + "type": "string" + }, + "consent_date": { + "description": "When consent was given", + "type": "string" + } + }, + "type": "object" + }, + "paypal_agentic_commerce_v1_value_terms_acceptance_value": { + "required": [ + "accepted", + "termsVersions" + ], + "properties": { + "accepted": { + "description": "Whether terms were accepted", + "type": "boolean" + }, + "terms_versions": { + "description": "Version of terms accepted", + "type": "string" + }, + "acceptance_date": { + "description": "When terms were accepted", + "type": "string" + }, + "ip_address": { + "description": "IP address of acceptance", + "type": "string" + } + }, + "type": "object" } } }, diff --git a/src/Resources/app/administration/src/types/openapi.d.ts b/src/Resources/app/administration/src/types/openapi.d.ts index cb9301c21..cfffeeb26 100644 --- a/src/Resources/app/administration/src/types/openapi.d.ts +++ b/src/Resources/app/administration/src/types/openapi.d.ts @@ -4,6 +4,9 @@ */ +/** WithRequired type helpers */ +type WithRequired = T & { [P in K]-?: T[P] }; + export interface paths { "/_action/paypal/saleschannel-default": { /** @description Sets PayPal as the default payment method for a given SalesChannel, or all. */ @@ -132,6 +135,9 @@ export interface paths { "/_action/paypal/webhook/execute": { post: operations["executeWebhook"]; }; + "/_action/paypal/honey/webhook/register/{salesChannelId}": { + post: operations["registerHoneyWebhook"]; + }; } export type webhooks = Record; @@ -1410,6 +1416,713 @@ export interface components { liveCredentialsValid: boolean | null; webhookErrors: string[]; }; + paypal_agentic_commerce_v1_address: { + /** + * @description The first line of the address, such as number and street, for example, 173 Drury Lane. + * Needed for data entry, and Compliance and Risk checks. This field needs to pass the full address. + */ + address_line1?: string; + address_line2?: string; + /** + * @description The highest-level sub-division in a country, which is usually a province, state, or ISO-3166-2 subdivision. + * This data is formatted for postal delivery, for example, CA and not California. Value, by country, is UK. + * A county. US. A state. Canada. A province. Japan. A prefecture. Switzerland. A kanton. + */ + admin_area1?: string; + /** @description A city, town, or village. Smaller than admin_area_level_1. */ + admin_area2?: string; + /** + * @description The postal code, which is the ZIP code or equivalent. + * Typically required for countries with a postal code or an equivalent. See postal code. + */ + postal_code?: string; + /** @description The 2-character ISO 3166-1 alpha-2 country code */ + country_code?: string; + }; + paypal_agentic_commerce_v1_agent_error_detail: unknown; + paypal_agentic_commerce_v1_applied_coupon: { + code: string; + description: string; + discount_amount: components["schemas"]["paypal_agentic_commerce_v1_money"]; + }; + /** + * @description Billing address for merchant business purposes, obtained from customer's PayPal profile. Similar to shipping addresses, billing addresses can be retrieved from customer's default address information stored in their PayPal account. + * + * When Billing Address is Available: + * + * Customer has a default billing address in their PayPal profile + * PayPal Credit and Buy Now Pay Later transactions + * Guest checkout with credit/debit cards + * User explicitly consents to address sharing + * Required for tax compliance and regulatory reporting + * + * Primary Use Cases: + * + * Tax calculation: Sales tax/VAT rates determined by billing jurisdiction + * Export compliance: Product restrictions based on customer's billing country + * Financial reporting: Accounting systems requiring customer billing location + * Address verification: Comparing billing vs shipping addresses for fraud prevention + * + * Secondary Use Cases: + * + * Business intelligence: Customer demographics and market analysis + * B2B invoicing: Legal invoices requiring customer billing details + * Compliance reporting: Regulatory requirements based on customer location + * + * Note: Payment verification (AVS) and chargeback protection are handled by PayPal internally. + * + * Implementation Notes: + * + * Billing address is typically available from customer profile data + * Can be populated during cart creation if customer provides it + * Falls back to shipping address when billing address is not specified + * Merchants should handle graceful fallback scenarios + */ + paypal_agentic_commerce_v1_billing_address: WithRequired; + paypal_agentic_commerce_v1_cart_item: { + /** @description Unique product identifier (optional in v1 for backwards compatibility) */ + item_id?: string; + /** @description Product variant identifier (color, size, etc.) - unique id of the product */ + variant_id?: string; + /** @description Item grouping identifier - passed when item is part of a group in honey catalog */ + parent_id?: string; + /** @description Number of items */ + quantity: number; + /** @description Product display name */ + name?: string; + /** @description Product description */ + description?: string; + /** @description URL for product details page */ + item_url?: string; + price: components["schemas"]["paypal_agentic_commerce_v1_money"]; + selected_attributes?: components["schemas"]["paypal_agentic_commerce_v1_referral_selected_attribute"][]; + gift_options?: components["schemas"]["paypal_agentic_commerce_v1_gift_options"]; + custom_options?: components["schemas"]["paypal_agentic_commerce_v1_referral_custom_option"][]; + }; + paypal_agentic_commerce_v1_cart_totals: { + subtotal?: components["schemas"]["paypal_agentic_commerce_v1_money"]; + discount?: components["schemas"]["paypal_agentic_commerce_v1_money"]; + shipping?: components["schemas"]["paypal_agentic_commerce_v1_money"]; + tax?: components["schemas"]["paypal_agentic_commerce_v1_money"]; + handling?: components["schemas"]["paypal_agentic_commerce_v1_money"]; + insurance?: components["schemas"]["paypal_agentic_commerce_v1_money"]; + shipping_discount?: components["schemas"]["paypal_agentic_commerce_v1_money"]; + custom_charges?: components["schemas"]["paypal_agentic_commerce_v1_money"]; + total: components["schemas"]["paypal_agentic_commerce_v1_money"]; + }; + paypal_agentic_commerce_v1_checkout_field: { + /** + * @description PayPal-approved checkout field type + * @enum {string} + */ + type: "AGE_VERIFICATION_18_PLUS" | "AGE_VERIFICATION_21_PLUS" | "GIFT_RECIPIENT_EMAIL" | "GIFT_RECIPIENT_NAME" | "GIFT_MESSAGE" | "DELIVERY_INSTRUCTIONS" | "DELIVERY_DATE_PREFERENCE" | "ALLERGY_INFORMATION" | "CUSTOM_ENGRAVING_TEXT" | "CUSTOM_SIZING_INFO" | "TERMS_ACCEPTANCE" | "PRIVACY_CONSENT"; + /** + * @description Field completion and validation status: + * + * PENDING: Field needs customer input + * + * Initial state when field is required + * AI agent should collect this information + * value field is null or empty + * + * COMPLETED: Valid value provided and accepted + * + * Customer provided acceptable input + * Value passes all validation rules + * Cart can proceed with this field resolved + * + * REJECTED: Invalid or unacceptable value provided + * + * Customer provided input that doesn't meet requirements + * validation_issue explains the specific problem + * AI agent should request corrected input + * + * ERROR: System error during processing + * + * Technical failure in field processing + * Should retry or escalate to support + * Not caused by customer input + * @enum {string} + */ + status: "PENDING" | "COMPLETED" | "REJECTED" | "ERROR"; + /** + * @description Structured value based on field type. Each checkout field type has a specific value schema. + * Use oneOf to validate against the appropriate structure for the field type. + */ + value?: components["schemas"]["paypal_agentic_commerce_v1_value_age_verification_value"] | components["schemas"]["paypal_agentic_commerce_v1_value_gift_recipient_email_value"] | components["schemas"]["paypal_agentic_commerce_v1_value_gift_recipient_name_value"] | components["schemas"]["paypal_agentic_commerce_v1_value_gift_message_value"] | components["schemas"]["paypal_agentic_commerce_v1_value_delivery_instructions_value"] | components["schemas"]["paypal_agentic_commerce_v1_value_delivery_date_preference_value"] | components["schemas"]["paypal_agentic_commerce_v1_value_allergy_information_value"] | components["schemas"]["paypal_agentic_commerce_v1_value_custom_engraving_text_value"] | components["schemas"]["paypal_agentic_commerce_v1_value_custom_sizing_info_value"] | components["schemas"]["paypal_agentic_commerce_v1_value_terms_acceptance_value"] | components["schemas"]["paypal_agentic_commerce_v1_value_privacy_consent_value"]; + /** + * @description Additional context and metadata for the checkout field. + * This is a flexible object that can contain any field-specific information needed for validation, display, or processing. + * The structure varies based on the field type. + */ + context?: Record; + validation_issue?: components["schemas"]["paypal_agentic_commerce_v1_validation_issue"]; + }; + paypal_agentic_commerce_v1_context_business_rule_error_context: { + /** @description Specific business rule issue type */ + specific_issue: string; + /** @description Current order amount */ + current_amount: string; + /** @description Required minimum amount */ + required_amount: string; + /** @description Maximum allowed amount */ + maximum_amount: string; + /** @description Amount needed to meet minimum */ + remaining_amount: string; + /** @description Customer account status */ + account_status: string; + /** @description Reason for account suspension */ + suspension_reason: string; + /** @description Date of account suspension */ + suspension_date: string; + /** @description Monthly purchase limit */ + monthly_limit: string; + /** @description Current month purchase total */ + current_month_total: string; + /** @description When limits reset */ + reset_date: string; + /** @description Total quantity in bulk order */ + total_quantity: number; + /** @description Quantity requiring approval */ + approval_threshold: number; + /** @description When maintenance ends */ + maintenance_end_time: string; + /** @description Current service status */ + service_status: string; + /** @description Seconds before retry recommended */ + retry_after: number; + /** @description Support contact information */ + contact_info: string; + /** @description Items with restrictions */ + restricted_items: string[]; + /** @description Required minimum age */ + age_requirement: number; + /** @description Store business hours */ + business_hours: components["schemas"]["paypal_agentic_commerce_v1_referral_business_hour"][]; + /** @description Amount needed to meet minimum requirements */ + shortage_amount: string; + /** @description Amount by which limit is exceeded */ + exceeds_by: string; + }; + paypal_agentic_commerce_v1_context_data_error_context: { + /** @description Specific business rule issue type */ + specific_issue: string; + /** @description Name of the field with validation error */ + field_name: string; + /** @description Value that failed validation */ + provided_value: string; + /** @description Expected format description */ + expected_format: string; + /** @description Maximum allowed length */ + max_length: number; + /** @description Minimum required length */ + min_length: number; + /** @description Current value length */ + current_length: number; + /** @description Required regex pattern */ + regex_pattern: string; + /** @description Suggested corrected value */ + suggested_value: string; + /** @description List of allowed values for enum fields */ + allowed_values: string[]; + /** @description List of required field names */ + required_fields: string[]; + /** @description Descriptions for required fields */ + field_descriptions: string[]; + }; + paypal_agentic_commerce_v1_context_inventory_issue_context: { + /** @description Specific business rule issue type */ + specific_issue: string; + /** @description Product item identifier */ + item_id: string; + /** @description Product variant identifier if applicable */ + variant_id: string; + /** @description Currently available quantity */ + available_quantity: number; + requested_quantity: number; + /** @description Quantity reserved for other transactions */ + reserved_quantity: number; + /** @description Expected restock date */ + restock_date: string; + /** @description Estimated shipping date for back-orders */ + estimated_ship_date: string; + /** @description Maximum allowed back-order quantity */ + back_order_limit: number; + current_back_orders: number; + discontinuation_date: string; + /** @description Alternative product IDs */ + suggested_alternatives: string[]; + /** @description Whether newer version is available */ + upgrade_available: boolean; + /** @description When seasonal product becomes available */ + seasonal_start_date: string; + /** @description When item was last sold */ + last_sold: string; + }; + paypal_agentic_commerce_v1_context_payment_error_context: { + /** @description Specific business rule issue type */ + specific_issue: string; + /** @description Total order amount */ + order_total: string; + /** @description Maximum payment limit */ + payment_limit: string; + /** @description Minimum payment amount */ + minimum_amount: string; + /** @description Amount exceeding limit */ + excess_amount: string; + /** @description Payment method being used */ + payment_method: string; + /** @description Transaction currency */ + currency_code: string; + /** @description Source currency for conversion */ + from_currency: string; + /** @description Target currency for conversion */ + to_currency: string; + /** @description Currency conversion service status */ + conversion_service: string; + /** @description List of supported payment methods */ + supported_payment_methods: string[]; + /** @description Payment processor specific error code */ + processor_error_code: string; + /** @description Reason for payment decline */ + decline_reason: string; + /** @description Payment token that was declined */ + payment_token: string; + }; + paypal_agentic_commerce_v1_context_pricing_error_context: { + /** @description Specific business rule issue type */ + specific_issue: string; + /** @description Item with pricing issue */ + item_id: string; + /** @description Original price value */ + original_price: string; + /** @description Current price value */ + current_price: string; + /** @description Currency code */ + currency_code: string; + /** + * @description Reason for price change + * @enum {string} + */ + price_change_reason: "promotional_ended" | "promotional_started" | "market_adjustment" | "cost_increase" | "seasonal_pricing" | "component_cost_increase" | "terms_updated"; + /** @description Amount of price increase */ + price_increase: string; + /** @description Amount of price decrease */ + price_decrease: string; + /** @description Coupon code with issues */ + coupon_code: string; + /** @description Coupon usage limit */ + usage_limit: number; + /** @description Current coupon usage count */ + current_usage: number; + /** @description Discount expiration date */ + expiration_date: string; + /** @description Minimum order for discount */ + minimum_order_amount: string; + /** @description List of supported currencies */ + supported_currencies: string[]; + /** @description Multiple currencies found in cart */ + found_currencies: string[]; + /** @description Tax calculation service error */ + tax_service_error: string; + /** @description Current system date for comparisons */ + current_date: string; + /** @description Discount amount that was applied */ + discount_amount: string; + /** @description Whether all items must use same currency */ + required_currency_consistency: boolean; + /** @description Items with different currencies */ + mixed_items: components["schemas"]["paypal_agentic_commerce_v1_referral_mixed_item"][]; + }; + paypal_agentic_commerce_v1_context_shipping_error_context: { + /** @description Specific business rule issue type */ + specific_issue: string; + /** @description Specific address validation failures */ + validation_failures: string[]; + /** @description Suggested address corrections */ + suggested_corrections: components["schemas"]["paypal_agentic_commerce_v1_referral_suggested_correction"][]; + /** + * Format: float + * @description Address validation quality score + */ + address_quality_score: number; + /** @description Items with shipping restrictions */ + restricted_items: string[]; + /** + * @description Reason for shipping restriction + * @enum {string} + */ + restriction_reason: "signature_required" | "age_verification_required" | "export_controlled" | "hazardous_material" | "oversized_item" | "po_box_restriction"; + /** @description Whether PO Box was detected */ + po_box_detected: boolean; + /** @description Destination country code */ + destination_country: string; + /** @description Restricted region identifier */ + restricted_region: string; + /** @description List of supported countries */ + supported_countries: string[]; + /** @description Address string that failed validation */ + provided_address: string; + }; + paypal_agentic_commerce_v1_coupon: { + /** @description Coupon code identifier */ + code: string; + /** + * @description Action to perform on this specific coupon + * @enum {string} + */ + action: "APPLY" | "REMOVE"; + }; + paypal_agentic_commerce_v1_customer: { + name: components["schemas"]["paypal_agentic_commerce_v1_referral_customer_name"]; + phone: components["schemas"]["paypal_agentic_commerce_v1_phone"]; + /** + * @description The internationalized email address. + * Note: Up to 64 characters are allowed before and 255 characters are allowed after the @ sign. + * However, the generally accepted maximum length for an email address is 254 characters. + * The pattern verifies that an unquoted @ sign exists. + */ + email_address: string; + }; + paypal_agentic_commerce_v1_error: { + /** @description Error name/type */ + name: string; + /** @description Error description */ + message: string; + /** @description Unique error identifier for support */ + debug_id?: string; + /** @description Detailed error information */ + details?: components["schemas"]["paypal_agentic_commerce_v1_agent_error_detail"][]; + }; + paypal_agentic_commerce_v1_geo_coordinates: { + /** @description Latitude coordinate in decimal degrees (-90 to 90). WGS84 datum. */ + latitude: string; + /** @description Longitude coordinate in decimal degrees (-180 to 180). WGS84 datum. */ + longitude: string; + /** + * @description Administrative subdivision code (state, province, region). + * ISO 3166-2 format without country prefix (e.g., 'CA' for California, 'ON' for Ontario). + */ + subdivision: string; + /** @description ISO 3166-1 alpha-2 country code for the coordinate location. */ + country_code: string; + }; + paypal_agentic_commerce_v1_gift_options: { + /** @description Whether this is a gift */ + is_gift: boolean; + recipient: components["schemas"]["paypal_agentic_commerce_v1_referral_recipient"]; + /** + * @description Scheduled delivery date in RFC3339 format. Seconds are required while fractional seconds are optional. + * + * example: 2024-12-25T09:00:00Z + */ + delivery_date: string; + /** @description Name of gift sender */ + sender_name: string; + /** @description Personal message (max 500 characters) */ + gift_message: string; + /** @description Whether to include gift wrapping */ + gift_wrap: boolean; + }; + paypal_agentic_commerce_v1_link: { + /** + * @description Link relationship type + * @enum {string} + */ + rel: "rel" | "update" | "checkout"; + /** + * @description Target URL for the link + * + * example: https://your-domain.com/api/paypal/v1/merchant-cart/CART-123 + */ + href: string; + /** + * @description HTTP method for the link + * @enum {string} + */ + method?: "GET" | "POST" | "PUT"; + /** @description Human-readable description of the link */ + title?: string; + /** @description Expected content type */ + type?: string; + }; + paypal_agentic_commerce_v1_money: { + /** @description The 3-character ISO-4217 currency code that identifies the currency. */ + currency_code?: string; + /** @description The value, which might be: An integer for currencies like JPY that are not typically fractional. A decimal fraction for currencies like TND that are subdivided into thousandths. For the required number of decimal places for a currency code, see Currency Codes. */ + value: string; + }; + paypal_agentic_commerce_v1_pay_pal_cart: { + id?: string; + /** @enum {string} */ + status?: "CREATED" | "COMPLETE" | "READY" | "INCOMPLETE"; + /** @enum {string} */ + validation_status?: "VALID" | "INVALID" | "REQUIRES_ADDITIONAL_INFORMATION"; + /** @description List of issues preventing checkout (empty = ready) */ + validation_issues?: components["schemas"]["paypal_agentic_commerce_v1_validation_issue"][]; + totals?: components["schemas"]["paypal_agentic_commerce_v1_cart_totals"]; + /** @description Successfully applied coupons (server-calculated) */ + applied_coupons?: components["schemas"]["paypal_agentic_commerce_v1_applied_coupon"][]; + /** @description Available shipping methods with selection state */ + available_shipping_options?: components["schemas"]["paypal_agentic_commerce_v1_shipping_option"][]; + /** @description HATEOAS navigation links for cart operations */ + links?: components["schemas"]["paypal_agentic_commerce_v1_link"][]; + /** @description Products in the cart */ + items: components["schemas"]["paypal_agentic_commerce_v1_cart_item"][]; + customer?: components["schemas"]["paypal_agentic_commerce_v1_customer"]; + shipping_address?: components["schemas"]["paypal_agentic_commerce_v1_shipping_address"]; + billing_address?: components["schemas"]["paypal_agentic_commerce_v1_billing_address"]; + payment_method?: components["schemas"]["paypal_agentic_commerce_v1_payment_method"]; + /** @description Custom checkout fields (age verification, etc.) */ + checkout_fields?: components["schemas"]["paypal_agentic_commerce_v1_checkout_field"][]; + /** @description Discount coupons to apply or remove from cart */ + coupons?: components["schemas"]["paypal_agentic_commerce_v1_coupon"][]; + geo_coordinates?: components["schemas"]["paypal_agentic_commerce_v1_geo_coordinates"]; + }; + paypal_agentic_commerce_v1_payment_method: { + /** + * @description Payment method type - only PayPal is supported by this API + * @enum {string} + */ + type: "paypal"; + /** @description PayPal payment token from cart creation or customer approval */ + token?: string; + /** @description PayPal payer identifier provided after customer approval */ + payer_id?: string; + /** @description URL used to inform merchant that the PayPal buyer approved the order */ + approval_url?: string; + }; + paypal_agentic_commerce_v1_phone: { + /** + * @description The country calling code (CC), in its canonical international E.164 numbering plan format. + * The combined length of the CC and the national number must not be greater than 15 digits. + * The national number consists of a national destination code (NDC) and subscriber number (SN) + */ + country_code?: string; + /** + * @description The national number, in its canonical international E.164 numbering plan format. + * The combined length of the country calling code (CC) and the national number must not be greater than 15 digits. + * The national number consists of a national destination code (NDC) and subscriber number (SN). + */ + national_number?: string; + /** @description The extension number */ + extension_number?: string; + }; + paypal_agentic_commerce_v1_referral_business_hour: { + open_time: string; + close_time: string; + timezone: string; + }; + paypal_agentic_commerce_v1_referral_custom_option: { + name: string; + value: string; + price_modifier: string; + }; + paypal_agentic_commerce_v1_referral_customer_name: { + given_name: string; + surname: string; + }; + paypal_agentic_commerce_v1_referral_measurements: { + chest: string; + waist: string; + height: string; + weight: string; + }; + paypal_agentic_commerce_v1_referral_meta_data: { + cost_impact: string; + priority: string; + waist: string; + auto_applicable: boolean; + estimated_time: string; + redirect_required: boolean; + }; + paypal_agentic_commerce_v1_referral_mixed_item: { + item_id: string; + currency: string; + }; + paypal_agentic_commerce_v1_referral_recipient: { + name: string; + email: string; + phone: string; + }; + paypal_agentic_commerce_v1_referral_selected_attribute: { + name: string; + value: string; + }; + paypal_agentic_commerce_v1_referral_suggested_correction: { + postal_code: string; + address_line1: string; + admin_area2: string; + }; + paypal_agentic_commerce_v1_resolution_option: { + /** + * @description Machine-readable action identifier + * @enum {string} + */ + action: "REDIRECT_TO_MERCHANT" | "MODIFY_CART" | "ACCEPT_NEW_PRICE" | "ACCEPT_BACK_ORDER" | "SUGGEST_ALTERNATIVE" | "REMOVE_ITEM" | "UPDATE_ADDRESS" | "PROVIDE_MISSING_FIELD" | "USE_DIFFERENT_PAYMENT" | "SPLIT_ORDER" | "CONTACT_SUPPORT" | "RETRY_LATER" | "REQUEST_APPROVAL" | "WAIT_FOR_RESTOCK" | "USE_DIFFERENT_CURRENCY" | "ACCEPT_PRE_ORDER" | "UPDATE_SHIPPING_METHOD" | "ACCEPT_TERMS" | "VERIFY_ACCOUNT" | "APPLY_DIFFERENT_COUPON" | "REMOVE_COUPON" | "CHOOSE_DIFFERENT_VARIANT"; + /** @description Human-readable action label */ + label: string; + /** @description URL to redirect to for resolution */ + url?: string; + metadata?: components["schemas"]["paypal_agentic_commerce_v1_referral_meta_data"]; + }; + paypal_agentic_commerce_v1_shipping_address: WithRequired; + paypal_agentic_commerce_v1_shipping_option: { + /** @description Unique shipping option identifier */ + id?: string; + /** @description Display name */ + name?: string; + /** @description Detailed description */ + description?: string; + price: components["schemas"]["paypal_agentic_commerce_v1_money"]; + /** @description Whether this shipping option is currently selected */ + is_selected?: boolean; + /** @description Estimated delivery date in YYYY-MM-DD format */ + estimated_delivery?: string; + }; + paypal_agentic_commerce_v1_validation_issue: { + /** + * @description Consolidated error category + * @enum {string} + */ + code: "INVENTORY_ISSUE" | "PRICING_ERROR" | "SHIPPING_ERROR" | "PAYMENT_ERROR" | "DATA_ERROR" | "BUSINESS_RULE_ERROR"; + /** + * @description Type classification for error handling + * @enum {string} + */ + type: "MISSING_FIELD" | "INVALID_DATA" | "BUSINESS_RULE"; + /** @description Technical message for developers and logging */ + message: string; + /** @description Customer-friendly message for end users */ + user_message?: string; + /** @description Specific item ID if the issue is item-specific */ + item_id?: string; + /** @description Specific field name if the issue is field-specific */ + field?: string; + /** @description Category-specific context information */ + context?: components["schemas"]["paypal_agentic_commerce_v1_context_inventory_issue_context"] | components["schemas"]["paypal_agentic_commerce_v1_context_pricing_error_context"] | components["schemas"]["paypal_agentic_commerce_v1_context_shipping_error_context"] | components["schemas"]["paypal_agentic_commerce_v1_context_payment_error_context"] | components["schemas"]["paypal_agentic_commerce_v1_context_data_error_context"] | components["schemas"]["paypal_agentic_commerce_v1_context_business_rule_error_context"] | null; + /** @description Available actions to resolve this issue */ + resolution_options?: components["schemas"]["paypal_agentic_commerce_v1_resolution_option"][]; + }; + paypal_agentic_commerce_v1_value_age_verification_value: { + /** @description Whether age verification was confirmed */ + confirmed: boolean; + /** + * @description Method used for age verification + * @enum {string} + */ + verificationMethod?: "self_declaration" | "id_verification" | "third_party"; + /** @description When verification was completed */ + verificationDate?: string; + }; + paypal_agentic_commerce_v1_value_allergy_information_value: { + /** @description List of known allergies */ + allergies: string[]; + /** + * @description Allergy severity level + * @enum {string} + */ + severity: "life_threatening" | "mild" | "moderate" | "severe"; + /** @description Medications to avoid */ + medications: string[]; + /** + * @description Emergency contact information + * + * example: +1-555-999-8888 + */ + emergency_contact: string; + }; + paypal_agentic_commerce_v1_value_custom_engraving_text_value: { + /** @description Text to be engraved */ + text: string; + /** + * @description Preferred font style + * @enum {string} + */ + font?: "arial" | "times" | "script" | "block"; + /** + * @description Text size preference + * @enum {string} + */ + size?: "small" | "medium" | "large"; + /** + * @description Engraving position + * @enum {string} + */ + position?: "front" | "back" | "side" | "bottom"; + }; + paypal_agentic_commerce_v1_value_custom_sizing_info_value: { + measurements: components["schemas"]["paypal_agentic_commerce_v1_referral_measurements"]; + /** + * @description Fit preference + * @enum {string} + */ + size_preference: "tight" | "regular" | "loose"; + /** @description Special sizing requirements */ + special_requirements: string; + }; + paypal_agentic_commerce_v1_value_delivery_date_preference_value: { + /** @description Preferred delivery date */ + preferred_date: string; + /** + * @description Preferred time window + * @enum {string} + */ + time_window: "morning" | "afternoon" | "evening" | "anytime"; + /** @description Specific preferred time (HH:MM format) */ + specific_time: string; + }; + paypal_agentic_commerce_v1_value_delivery_instructions_value: { + /** @description Special delivery instructions */ + instructions: string; + /** @description Building or gate access code */ + access_code?: string; + /** @description Contact phone for delivery */ + contact_phone?: string; + }; + paypal_agentic_commerce_v1_value_gift_message_value: { + /** @description Personal message for the recipient */ + message: string; + /** @description Name of the person sending the gift */ + sender_name?: string; + }; + paypal_agentic_commerce_v1_value_gift_recipient_email_value: { + /** @description Recipient's email address */ + email: string; + /** @description Whether email was verified */ + verified?: boolean; + }; + paypal_agentic_commerce_v1_value_gift_recipient_name_value: { + /** @description Recipient's full name */ + name: string; + /** @description Recipient's first name */ + first_name?: string; + /** @description Recipient's last name */ + last_name?: string; + }; + paypal_agentic_commerce_v1_value_privacy_consent_value: { + /** @description Whether privacy policy was consented to */ + consented: boolean; + /** + * @description Types of consent given + * @enum {array} + */ + consent_types?: analytics | third_party_sharing | data_processing | marketing; + /** @description Privacy policy version */ + policy_version?: string; + /** @description When consent was given */ + consent_date?: string; + }; + paypal_agentic_commerce_v1_value_terms_acceptance_value: { + /** @description Whether terms were accepted */ + accepted: boolean; + /** @description Version of terms accepted */ + terms_versions?: string; + /** @description When terms were accepted */ + acceptance_date?: string; + /** @description IP address of acceptance */ + ip_address?: string; + }; }; responses: never; parameters: never; @@ -2138,4 +2851,22 @@ export interface operations { }; }; }; + registerHoneyWebhook: { + parameters: { + path: { + salesChannelId: string; + }; + }; + responses: { + /** @description Returns the action taken for the webhook registration */ + 200: { + content: { + "application/json": { + success?: boolean; + message?: string; + }; + }; + }; + }; + }; } diff --git a/src/Resources/config/services/agent_commerce.xml b/src/Resources/config/services/agent_commerce.xml index 7ee6b40c3..af405471e 100644 --- a/src/Resources/config/services/agent_commerce.xml +++ b/src/Resources/config/services/agent_commerce.xml @@ -22,7 +22,6 @@ - @@ -80,6 +79,7 @@ + diff --git a/src/Util/IntrospectionProcessor.php b/src/Util/IntrospectionProcessor.php index fc80b8d78..7070ba74d 100644 --- a/src/Util/IntrospectionProcessor.php +++ b/src/Util/IntrospectionProcessor.php @@ -15,9 +15,11 @@ use Shopware\Core\Framework\HttpException; use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\ShopwareHttpException; +use Swag\PayPal\AgentCommerce\Exception\AgentHttpException; use Swag\PayPal\Pos\Api\Exception\PosException; use Swag\PayPal\Pos\Client\AbstractClient as PosAbstractClient; use Swag\PayPal\RestApi\Client\AbstractClient; +use Swag\PayPal\RestApi\PayPalApiStruct; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; /** @@ -150,6 +152,10 @@ private function exceptionToContext(\Throwable $exception): array 'line' => $exception->getLine(), ]; + if ($exception instanceof AgentHttpException) { + $context['details'] = $exception->getDetails()->map(static fn (PayPalApiStruct $e): array => $e->jsonSerialize()); + } + if ($exception instanceof ShopwareHttpException) { $context['parameters'] = $exception->getParameters(); } diff --git a/tests/AgentCommerce/Exception/AgentExceptionTest.php b/tests/AgentCommerce/Exception/AgentExceptionTest.php index d8bae0c34..c425658d0 100644 --- a/tests/AgentCommerce/Exception/AgentExceptionTest.php +++ b/tests/AgentCommerce/Exception/AgentExceptionTest.php @@ -5,7 +5,7 @@ * file that was distributed with this source code. */ -namespace Swag\PayPal\Tests\AgentCommerce\Exception; +namespace Swag\PayPal\Test\AgentCommerce\Exception; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; diff --git a/tests/AgentCommerce/Exception/AgentHttpExceptionTest.php b/tests/AgentCommerce/Exception/AgentHttpExceptionTest.php index f6f6d2084..f01ccb601 100644 --- a/tests/AgentCommerce/Exception/AgentHttpExceptionTest.php +++ b/tests/AgentCommerce/Exception/AgentHttpExceptionTest.php @@ -5,15 +5,14 @@ * file that was distributed with this source code. */ -namespace Swag\PayPal\Tests\AgentCommerce\Exception; +namespace Swag\PayPal\Test\AgentCommerce\Exception; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use Shopware\Core\Framework\Log\Package; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\AgentErrorDetail; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\AgentErrorDetailCollection; -use Shopware\PayPalSDK\Struct\Struct; use Swag\PayPal\AgentCommerce\Exception\AgentHttpException; +use Swag\PayPal\AgentCommerce\Struct\V1\AgentErrorDetail; +use Swag\PayPal\AgentCommerce\Struct\V1\AgentErrorDetailCollection; /** * @internal @@ -24,7 +23,8 @@ class AgentHttpExceptionTest extends TestCase { public function testPublicAPI(): void { - $detail1 = Struct::from(AgentErrorDetail::class, [ + $detail1 = new AgentErrorDetail(); + $detail1->assign([ 'field' => 'foo', 'issue' => 'bar', 'description' => 'baz', diff --git a/tests/AgentCommerce/HoneyWebhookServiceTest.php b/tests/AgentCommerce/HoneyWebhookServiceTest.php index 38ed0d7b5..3f589c90a 100644 --- a/tests/AgentCommerce/HoneyWebhookServiceTest.php +++ b/tests/AgentCommerce/HoneyWebhookServiceTest.php @@ -11,6 +11,9 @@ use GuzzleHttp\Exception\RequestException; use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; +use Lcobucci\JWT\Encoding\JoseEncoder; +use Lcobucci\JWT\Token\Parser; +use Lcobucci\JWT\UnencryptedToken; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -21,7 +24,6 @@ use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository; use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria; use Shopware\Core\Framework\DataAbstractionLayer\Search\EntitySearchResult; -use Shopware\Core\Framework\JWT\JWTDecoder; use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Uuid\Uuid; use Shopware\Core\System\Country\CountryCollection; @@ -33,6 +35,7 @@ use Shopware\Core\System\SalesChannel\SalesChannelEntity; use Shopware\Core\System\SystemConfig\SystemConfigService; use Swag\PayPal\AgentCommerce\Exception\HoneyWebhookException; +use Swag\PayPal\AgentCommerce\Exception\JWTException; use Swag\PayPal\AgentCommerce\HoneyWebhookService; use Swag\PayPal\AgentCommerce\Util\FaviconLoader; use Swag\PayPal\Setting\Service\CredentialsUtil; @@ -51,21 +54,21 @@ class HoneyWebhookServiceTest extends TestCase { public function testRegisterWebhook(): void { - $context = Context::createCLIContext(); + $context = Context::createDefaultContext(); $salesChannel = self::createSalesChannel(); $salesChannelRepository = $this->createMock(EntityRepository::class); $salesChannelRepository - ->expects($this->once()) + ->expects(static::once()) ->method('search') ->willReturn(new EntitySearchResult('sales_channel', 1, new SalesChannelCollection([$salesChannel]), null, new Criteria(), $context)); $client = $this->createMock(HoneyClientMock::class); $client - ->expects($this->once()) + ->expects(static::once()) ->method('request') ->with('POST', 'webhooks/sw/install') ->willReturnCallback(function (string $method, string $url, array $options) use ($salesChannel) { - $jwt = (new JWTDecoder())->decode($options['body']); + $jwt = $this->parseToken($options['body'])->claims()->all(); static::assertSame('SalesChannel name', $jwt['storeName']); static::assertSame('https://example.com/', $jwt['storeUrl']); @@ -82,7 +85,7 @@ public function testRegisterWebhook(): void $credentialsUtil = $this->createMock(CredentialsUtil::class); $credentialsUtil - ->expects($this->once()) + ->expects(static::once()) ->method('getMerchantPayerId') ->willReturn('SomeMerchantId'); @@ -92,24 +95,24 @@ public function testRegisterWebhook(): void $routeMock = $this->createMock(RouterInterface::class); $routeMock - ->expects($this->once()) + ->expects(static::once()) ->method('getRouteCollection') ->willReturn($routeCollection); $configServiceMock = $this->createMock(SystemConfigService::class); $configServiceMock - ->expects($this->once()) + ->expects(static::once()) ->method('get') ->with(Settings::AGENT_COMMERCE_ONBOARDED) ->willReturn(false); $configServiceMock - ->expects($this->once()) + ->expects(static::once()) ->method('set') ->with(Settings::AGENT_COMMERCE_ONBOARDED, true); $loggerMock = $this->createMock(LoggerInterface::class); $loggerMock - ->expects($this->once()) + ->expects(static::once()) ->method('log') ->with('info', 'PayPal agent commerce webhook install', [ 'success' => true, @@ -119,7 +122,7 @@ public function testRegisterWebhook(): void $faviconMock = $this->createMock(FaviconLoader::class); $faviconMock - ->expects($this->once()) + ->expects(static::once()) ->method('loadFaviconLink') ->willReturn('https://example.com/favicon.ico'); @@ -143,11 +146,11 @@ public function testDeregisterWebhook(): void { $client = $this->createMock(HoneyClientMock::class); $client - ->expects($this->once()) + ->expects(static::once()) ->method('request') ->with('POST', 'webhooks/sw/uninstall') ->willReturnCallback(function (string $method, string $url, array $options) { - $jwt = (new JWTDecoder())->decode($options['body']); + $jwt = $this->parseToken($options['body'])->claims()->all(); static::assertSame('SalesChannel name', $jwt['storeName']); static::assertSame('https://example.com/', $jwt['storeUrl']); @@ -163,18 +166,18 @@ public function testDeregisterWebhook(): void $configServiceMock = $this->createMock(SystemConfigService::class); $configServiceMock - ->expects($this->once()) + ->expects(static::once()) ->method('get') ->with(Settings::AGENT_COMMERCE_ONBOARDED) ->willReturn('eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdG9yZU5hbWUiOiJTYWxlc0NoYW5uZWwgbmFtZSIsInN0b3JlVXJsIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS8iLCJjb3VudHJ5IjoiREUiLCJjdXJyZW5jeSI6IkVVUiIsImZhdkljb24iOiJodHRwczovL2xvY2FsaG9zdC9mYXZpY29uLmljbyIsInNoaXBwaW5nQ291bnRyaWVzIjp7IjAxOTk4MGY5NDI2YzcxNmJhYTUzYmVmY2NjODRjNWM2IjoiREUiLCIwMTk5ODBmOTQyNmM3MTZiYWE1M2JlZmNjZDI4Y2Q3ZiI6IlVLIn0sInBheXBhbE1lcmNoYW50SWQiOiJTb21lTWVyY2hhbnRJZCIsInNob3B3YXJlTWVyY2hhbnRJZCI6IjAxOTk4MGY5NDI2YzcxNmJhYTUzYmVmY2QwODc5ZmI0IiwiY2F0YWxvZ0Rvd25sb2FkVXJsIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS90ZXN0L3BhdGgvZXhwb3J0In0.3K5rXCZGBgNFWOmZwTkVOV5AhCrr8VKgAS5ZPqsKeHI'); $configServiceMock - ->expects($this->once()) + ->expects(static::once()) ->method('delete') ->with(Settings::AGENT_COMMERCE_ONBOARDED); $loggerMock = $this->createMock(LoggerInterface::class); $loggerMock - ->expects($this->once()) + ->expects(static::once()) ->method('log') ->with('info', 'PayPal agent commerce webhook uninstall', [ 'success' => true, @@ -200,20 +203,20 @@ public function testDeregisterWebhook(): void public function testReRegister(): void { - $context = Context::createCLIContext(); + $context = Context::createDefaultContext(); $salesChannel = self::createSalesChannel(); $salesChannelRepository = $this->createMock(EntityRepository::class); $salesChannelRepository - ->expects($this->once()) + ->expects(static::once()) ->method('search') ->willReturn(new EntitySearchResult('sales_channel', 1, new SalesChannelCollection([$salesChannel]), null, new Criteria(), $context)); $client = $this->createMock(HoneyClientMock::class); $client - ->expects($this->exactly(2)) + ->expects(static::exactly(2)) ->method('request') ->willReturnCallback(function (string $method, string $url, array $options) use ($salesChannel) { - $jwt = (new JWTDecoder())->decode($options['body']); + $jwt = $this->parseToken($options['body'])->claims()->all(); static::assertSame('SalesChannel name', $jwt['storeName']); static::assertSame('https://example.com/', $jwt['storeUrl']); @@ -247,13 +250,13 @@ public function testReRegister(): void ->with(Settings::AGENT_COMMERCE_ONBOARDED) ->willReturn('eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdG9yZU5hbWUiOiJTYWxlc0NoYW5uZWwgbmFtZSIsInN0b3JlVXJsIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS8iLCJjb3VudHJ5IjoiREUiLCJjdXJyZW5jeSI6IkVVUiIsImZhdkljb24iOiJodHRwczovL2xvY2FsaG9zdC9mYXZpY29uLmljbyIsInNoaXBwaW5nQ291bnRyaWVzIjp7IjAxOTk4MGY5NDI2YzcxNmJhYTUzYmVmY2NjODRjNWM2IjoiREUiLCIwMTk5ODBmOTQyNmM3MTZiYWE1M2JlZmNjZDI4Y2Q3ZiI6IlVLIn0sInBheXBhbE1lcmNoYW50SWQiOiJTb21lTWVyY2hhbnRJZCIsInNob3B3YXJlTWVyY2hhbnRJZCI6IjAxOTk4MGY5NDI2YzcxNmJhYTUzYmVmY2QwODc5ZmI0IiwiY2F0YWxvZ0Rvd25sb2FkVXJsIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS90ZXN0L3BhdGgvZXhwb3J0In0.3K5rXCZGBgNFWOmZwTkVOV5AhCrr8VKgAS5ZPqsKeHI'); $configServiceMock - ->expects($this->once()) + ->expects(static::once()) ->method('set') ->with(Settings::AGENT_COMMERCE_ONBOARDED); $loggerMock = $this->createMock(LoggerInterface::class); $loggerMock - ->expects($this->exactly(2)) + ->expects(static::exactly(2)) ->method('log'); $service = new HoneyWebhookService( @@ -277,17 +280,17 @@ public function testFailedReRegister(): void $this->expectException(HoneyWebhookException::class); $this->expectExceptionMessage('JWT signature verification failed'); - $context = Context::createCLIContext(); + $context = Context::createDefaultContext(); $salesChannel = self::createSalesChannel(); $salesChannelRepository = $this->createMock(EntityRepository::class); $salesChannelRepository - ->expects($this->once()) + ->expects(static::once()) ->method('search') ->willReturn(new EntitySearchResult('sales_channel', 1, new SalesChannelCollection([$salesChannel]), null, new Criteria(), $context)); $client = $this->createMock(HoneyClientMock::class); $client - ->expects($this->exactly(2)) + ->expects(static::exactly(2)) ->method('request') ->willReturnCallback(function (): void { $response = new Response(400, body: (string) json_encode([ @@ -314,13 +317,13 @@ public function testFailedReRegister(): void ->with(Settings::AGENT_COMMERCE_ONBOARDED) ->willReturn('eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdG9yZU5hbWUiOiJTYWxlc0NoYW5uZWwgbmFtZSIsInN0b3JlVXJsIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS8iLCJjb3VudHJ5IjoiREUiLCJjdXJyZW5jeSI6IkVVUiIsImZhdkljb24iOiJodHRwczovL2xvY2FsaG9zdC9mYXZpY29uLmljbyIsInNoaXBwaW5nQ291bnRyaWVzIjp7IjAxOTk4MGY5NDI2YzcxNmJhYTUzYmVmY2NjODRjNWM2IjoiREUiLCIwMTk5ODBmOTQyNmM3MTZiYWE1M2JlZmNjZDI4Y2Q3ZiI6IlVLIn0sInBheXBhbE1lcmNoYW50SWQiOiJTb21lTWVyY2hhbnRJZCIsInNob3B3YXJlTWVyY2hhbnRJZCI6IjAxOTk4MGY5NDI2YzcxNmJhYTUzYmVmY2QwODc5ZmI0IiwiY2F0YWxvZ0Rvd25sb2FkVXJsIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS90ZXN0L3BhdGgvZXhwb3J0In0.3K5rXCZGBgNFWOmZwTkVOV5AhCrr8VKgAS5ZPqsKeHI'); $configServiceMock - ->expects($this->never()) + ->expects(static::never()) ->method('set') ->with(Settings::AGENT_COMMERCE_ONBOARDED, true); $loggerMock = $this->createMock(LoggerInterface::class); $loggerMock - ->expects($this->exactly(2)) + ->expects(static::exactly(2)) ->method('log'); $service = new HoneyWebhookService( @@ -343,17 +346,17 @@ public function testDeregisterNotRegistered(): void $client = $this->createMock(HoneyClientMock::class); $client - ->expects($this->never()) + ->expects(static::never()) ->method('request'); $configServiceMock = $this->createMock(SystemConfigService::class); $configServiceMock - ->expects($this->once()) + ->expects(static::once()) ->method('get') ->with(Settings::AGENT_COMMERCE_ONBOARDED) ->willReturn(null); $configServiceMock - ->expects($this->never()) + ->expects(static::never()) ->method('delete') ->with(Settings::AGENT_COMMERCE_ONBOARDED); @@ -376,16 +379,16 @@ public function testMissingSalesChannelDataRegister(?SalesChannelEntity $salesCh $this->expectException(HoneyWebhookException::class); $this->expectExceptionMessage($exceptionMessage); - $searchResult = new EntitySearchResult('sales_channel', 0, new SalesChannelCollection(), null, new Criteria(), Context::createCLIContext()); + $searchResult = new EntitySearchResult('sales_channel', 0, new SalesChannelCollection(), null, new Criteria(), Context::createDefaultContext()); if ($salesChannel) { - $searchResult = new EntitySearchResult('sales_channel', 1, new SalesChannelCollection([$salesChannel]), null, new Criteria(), Context::createCLIContext()); + $searchResult = new EntitySearchResult('sales_channel', 1, new SalesChannelCollection([$salesChannel]), null, new Criteria(), Context::createDefaultContext()); } - $context = Context::createCLIContext(); + $context = Context::createDefaultContext(); $salesChannel = self::createSalesChannel(); $salesChannelRepository = $this->createMock(EntityRepository::class); $salesChannelRepository - ->expects($this->once()) + ->expects(static::once()) ->method('search') ->willReturn($searchResult); @@ -402,7 +405,7 @@ public function testMissingSalesChannelDataRegister(?SalesChannelEntity $salesCh $client = $this->createMock(HoneyClientMock::class); $client - ->expects($this->never()) + ->expects(static::never()) ->method('request'); $service = new HoneyWebhookService( @@ -446,17 +449,17 @@ public function testInvalidRegisterRequest(): void $this->expectException(HoneyWebhookException::class); $this->expectExceptionMessage('JWT signature verification failed'); - $context = Context::createCLIContext(); + $context = Context::createDefaultContext(); $salesChannel = self::createSalesChannel(); $salesChannelRepository = $this->createMock(EntityRepository::class); $salesChannelRepository - ->expects($this->once()) + ->expects(static::once()) ->method('search') ->willReturn(new EntitySearchResult('sales_channel', 1, new SalesChannelCollection([$salesChannel]), null, new Criteria(), $context)); $client = $this->createMock(HoneyClientMock::class); $client - ->expects($this->once()) + ->expects(static::once()) ->method('request') ->with('POST', 'webhooks/sw/install') ->willReturnCallback(function (): void { @@ -475,24 +478,24 @@ public function testInvalidRegisterRequest(): void $routeMock = $this->createMock(RouterInterface::class); $routeMock - ->expects($this->once()) + ->expects(static::once()) ->method('getRouteCollection') ->willReturn($routeCollection); $configServiceMock = $this->createMock(SystemConfigService::class); $configServiceMock - ->expects($this->once()) + ->expects(static::once()) ->method('get') ->with(Settings::AGENT_COMMERCE_ONBOARDED) ->willReturn(null); $configServiceMock - ->expects($this->once()) + ->expects(static::once()) ->method('delete') ->with(Settings::AGENT_COMMERCE_ONBOARDED); $loggerMock = $this->createMock(LoggerInterface::class); $loggerMock - ->expects($this->once()) + ->expects(static::once()) ->method('log') ->with('error', 'PayPal agent commerce webhook install', [ 'success' => false, @@ -520,7 +523,7 @@ public function testInvalidDeregisterRequest(): void $client = $this->createMock(HoneyClientMock::class); $client - ->expects($this->once()) + ->expects(static::once()) ->method('request') ->with('POST', 'webhooks/sw/uninstall') ->willReturnCallback(function (): void { @@ -535,17 +538,17 @@ public function testInvalidDeregisterRequest(): void $configServiceMock = $this->createMock(SystemConfigService::class); $configServiceMock - ->expects($this->once()) + ->expects(static::once()) ->method('get') ->with(Settings::AGENT_COMMERCE_ONBOARDED) ->willReturn('eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdG9yZU5hbWUiOiJTYWxlc0NoYW5uZWwgbmFtZSIsInN0b3JlVXJsIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS8iLCJjb3VudHJ5IjoiREUiLCJjdXJyZW5jeSI6IkVVUiIsImZhdkljb24iOiJodHRwczovL2xvY2FsaG9zdC9mYXZpY29uLmljbyIsInNoaXBwaW5nQ291bnRyaWVzIjp7IjAxOTk4MGY5NDI2YzcxNmJhYTUzYmVmY2NjODRjNWM2IjoiREUiLCIwMTk5ODBmOTQyNmM3MTZiYWE1M2JlZmNjZDI4Y2Q3ZiI6IlVLIn0sInBheXBhbE1lcmNoYW50SWQiOiJTb21lTWVyY2hhbnRJZCIsInNob3B3YXJlTWVyY2hhbnRJZCI6IjAxOTk4MGY5NDI2YzcxNmJhYTUzYmVmY2QwODc5ZmI0IiwiY2F0YWxvZ0Rvd25sb2FkVXJsIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS90ZXN0L3BhdGgvZXhwb3J0In0.3K5rXCZGBgNFWOmZwTkVOV5AhCrr8VKgAS5ZPqsKeHI'); $configServiceMock - ->expects($this->never()) + ->expects(static::never()) ->method('set'); $loggerMock = $this->createMock(LoggerInterface::class); $loggerMock - ->expects($this->once()) + ->expects(static::once()) ->method('log') ->with('error', 'PayPal agent commerce webhook uninstall', [ 'success' => false, @@ -571,17 +574,17 @@ public function testInvalidRequestNoResponse(): void $this->expectException(RequestException::class); $this->expectExceptionMessage('Something went wrong'); - $context = Context::createCLIContext(); + $context = Context::createDefaultContext(); $salesChannel = self::createSalesChannel(); $salesChannelRepository = $this->createMock(EntityRepository::class); $salesChannelRepository - ->expects($this->once()) + ->expects(static::once()) ->method('search') ->willReturn(new EntitySearchResult('sales_channel', 1, new SalesChannelCollection([$salesChannel]), null, new Criteria(), $context)); $client = $this->createMock(HoneyClientMock::class); $client - ->expects($this->once()) + ->expects(static::once()) ->method('request') ->with('POST', 'webhooks/sw/install') ->willReturnCallback(function (): void { @@ -594,18 +597,18 @@ public function testInvalidRequestNoResponse(): void $routeMock = $this->createMock(RouterInterface::class); $routeMock - ->expects($this->once()) + ->expects(static::once()) ->method('getRouteCollection') ->willReturn($routeCollection); $configServiceMock = $this->createMock(SystemConfigService::class); $configServiceMock - ->expects($this->once()) + ->expects(static::once()) ->method('get') ->with(Settings::AGENT_COMMERCE_ONBOARDED) ->willReturn(false); $configServiceMock - ->expects($this->never()) + ->expects(static::never()) ->method('delete') ->with(Settings::AGENT_COMMERCE_ONBOARDED); @@ -667,4 +670,24 @@ private static function createSalesChannel(): SalesChannelEntity return $salesChannel; } + + private function parseToken(string $jwt): UnencryptedToken + { + if (!$jwt) { + throw JWTException::invalidJwt('JWT cannot be empty'); + } + + try { + $parser = new Parser(new JoseEncoder()); + $token = $parser->parse($jwt); + } catch (\Exception $e) { + throw JWTException::invalidJwt($e->getMessage(), $e); + } + + if (!$token instanceof UnencryptedToken) { + throw JWTException::invalidJwt('Incorrect token type'); + } + + return $token; + } } diff --git a/tests/AgentCommerce/Routing/AgentContextResolverListenerTest.php b/tests/AgentCommerce/Routing/AgentContextResolverListenerTest.php index 337368dca..089790b23 100644 --- a/tests/AgentCommerce/Routing/AgentContextResolverListenerTest.php +++ b/tests/AgentCommerce/Routing/AgentContextResolverListenerTest.php @@ -5,14 +5,13 @@ * file that was distributed with this source code. */ -namespace Swag\PayPal\Tests\AgentCommerce\Routing; +namespace Swag\PayPal\Test\AgentCommerce\Routing; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Routing\KernelListenerPriorities; use Shopware\Core\Framework\Routing\RequestContextResolverInterface; -use Shopware\Core\Test\Stub\Symfony\StubKernel; use Swag\PayPal\AgentCommerce\Routing\AgentContextResolverListener; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Event\ControllerEvent; @@ -41,11 +40,11 @@ public function testGetSubscribedEvents(): void public function testResolveContext(): void { $request = new Request(); - $event = new ControllerEvent(new StubKernel(), function (): void {}, $request, HttpKernelInterface::MAIN_REQUEST); + $event = new ControllerEvent($this->createMock(HttpKernelInterface::class), function (): void {}, $request, HttpKernelInterface::MAIN_REQUEST); $resolver = $this->createMock(RequestContextResolverInterface::class); $resolver - ->expects($this->once()) + ->expects(static::once()) ->method('resolve') ->with($request); diff --git a/tests/AgentCommerce/Routing/AgentRequestContextResolverTest.php b/tests/AgentCommerce/Routing/AgentRequestContextResolverTest.php index 35f53e538..cbf02f0be 100644 --- a/tests/AgentCommerce/Routing/AgentRequestContextResolverTest.php +++ b/tests/AgentCommerce/Routing/AgentRequestContextResolverTest.php @@ -5,7 +5,7 @@ * file that was distributed with this source code. */ -namespace Swag\PayPal\Tests\AgentCommerce\Routing; +namespace Swag\PayPal\Test\AgentCommerce\Routing; use Lcobucci\JWT\Configuration; use Lcobucci\JWT\Signer\Key\InMemory; @@ -21,7 +21,6 @@ use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository; use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria; use Shopware\Core\Framework\DataAbstractionLayer\Search\EntitySearchResult; -use Shopware\Core\Framework\JWT\JWTDecoder; use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Routing\RouteScope; use Shopware\Core\Framework\Routing\RouteScopeRegistry; @@ -116,7 +115,6 @@ public function testResolveWithContextIsSkipped(): void $resolver = new AgentRequestContextResolver( $this->createMock(DataValidator::class), $this->createMock(EntityRepository::class), - new JWTDecoder(), new RouteScopeRegistry([]), $this->createMock(SalesChannelContextService::class), ); @@ -140,7 +138,6 @@ public function testResolveWithWrongScopeDoesNothing(): void $resolver = new AgentRequestContextResolver( $this->createMock(DataValidator::class), $this->createMock(EntityRepository::class), - new JWTDecoder(), new RouteScopeRegistry([new AgentRouteScope(), $wrongScope]), $this->createMock(SalesChannelContextService::class), ); @@ -159,7 +156,6 @@ public function testResolveWithMissingAuthorizationHeader(): void $resolver = new AgentRequestContextResolver( $this->createMock(DataValidator::class), $this->createMock(EntityRepository::class), - new JWTDecoder(), new RouteScopeRegistry([new AgentRouteScope()]), $this->createMock(SalesChannelContextService::class), ); @@ -202,14 +198,13 @@ public function testResolveWithWrongPublicJWT(): void $export->setStorefrontSalesChannelId(Uuid::randomHex()); $entityRepository = $this->createMock(EntityRepository::class); $entityRepository - ->expects($this->once()) + ->expects(static::once()) ->method('search') ->willReturn(self::createSearchResult($export)); $resolver = new AgentRequestContextResolver( $this->createMock(DataValidator::class), $entityRepository, - new JWTDecoder(), new RouteScopeRegistry([new AgentRouteScope()]), $this->createMock(SalesChannelContextService::class), ); @@ -242,6 +237,9 @@ public function testResolveWithExpiredToken(): void $this->expectExceptionObject(AgentException::unauthorized('Invalid JWT token')); $resolver = $this->getContainer()->get(AgentRequestContextResolver::class); + + static::assertInstanceOf(AgentRequestContextResolver::class, $resolver); + $resolver->resolve($request); } @@ -255,7 +253,6 @@ public function testResolveWithWrongJWTHeader(): void $resolver = new AgentRequestContextResolver( $this->createMock(DataValidator::class), $this->createMock(EntityRepository::class), - new JWTDecoder(), new RouteScopeRegistry([new AgentRouteScope()]), $this->createMock(SalesChannelContextService::class), ); @@ -290,6 +287,8 @@ public function testResolveWithMalformedJWTClaims(array $claims): void $resolver = $this->getContainer()->get(AgentRequestContextResolver::class); + static::assertInstanceOf(AgentRequestContextResolver::class, $resolver); + $resolver->resolve($request); } @@ -325,14 +324,13 @@ public function testResolveWithWrongAgentScopeInRoute(): void $export->setStorefrontSalesChannelId(Uuid::randomHex()); $entityRepository = $this->createMock(EntityRepository::class); $entityRepository - ->expects($this->once()) + ->expects(static::once()) ->method('search') ->willReturn(self::createSearchResult($export)); $resolver = new AgentRequestContextResolver( $this->createMock(DataValidator::class), $entityRepository, - new JWTDecoder(), new RouteScopeRegistry([new AgentRouteScope()]), $this->createMock(SalesChannelContextService::class), ); @@ -388,23 +386,22 @@ public function testResolveWithWrongAgentScopeInRequest(): void $repo = $this->createMock(EntityRepository::class); $repo - ->expects($this->once()) + ->expects(static::once()) ->method('search') ->with(static::isInstanceOf(Criteria::class), $expectedContext) ->willReturn($productExportResult); $contextService = $this->createMock(SalesChannelContextService::class); $contextService - ->expects($this->once()) + ->expects(static::once()) ->method('get') ->willReturn( - Generator::generateSalesChannelContext($expectedContext) + Generator::createSalesChannelContext($expectedContext) ); $resolver = new AgentRequestContextResolver( $this->createMock(DataValidator::class), $repo, - new JWTDecoder(), new RouteScopeRegistry([new AgentRouteScope()]), $contextService, ); @@ -432,22 +429,21 @@ public function testResolve(): void $export->setStorefrontSalesChannelId(Uuid::randomHex()); $entityRepository = $this->createMock(EntityRepository::class); $entityRepository - ->expects($this->once()) + ->expects(static::once()) ->method('search') ->willReturn(self::createSearchResult($export)); - $salesChannelContext = Generator::generateSalesChannelContext(); + $salesChannelContext = Generator::createSalesChannelContext(); $salesChannelMock = $this->createMock(SalesChannelContextService::class); $salesChannelMock - ->expects($this->once()) + ->expects(static::once()) ->method('get') ->willReturn($salesChannelContext); $resolver = new AgentRequestContextResolver( $this->createMock(DataValidator::class), $entityRepository, - new JWTDecoder(), new RouteScopeRegistry([new AgentRouteScope()]), $salesChannelMock, ); diff --git a/tests/AgentCommerce/Routing/AgentRouteScopeTest.php b/tests/AgentCommerce/Routing/AgentRouteScopeTest.php index 9a8958eb1..5c6f254f1 100644 --- a/tests/AgentCommerce/Routing/AgentRouteScopeTest.php +++ b/tests/AgentCommerce/Routing/AgentRouteScopeTest.php @@ -5,7 +5,7 @@ * file that was distributed with this source code. */ -namespace Swag\PayPal\Tests\AgentCommerce\Routing; +namespace Swag\PayPal\Test\AgentCommerce\Routing; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -100,7 +100,7 @@ public function testIsAllowed(): void $salesChannelContext = $this->createMock(SalesChannelContext::class); $salesChannelContext - ->expects($this->once()) + ->expects(static::once()) ->method('getContext') ->willReturn(Context::createDefaultContext($source)); diff --git a/tests/AgentCommerce/Routing/AgentSourceTest.php b/tests/AgentCommerce/Routing/AgentSourceTest.php index baaa99131..78934d657 100644 --- a/tests/AgentCommerce/Routing/AgentSourceTest.php +++ b/tests/AgentCommerce/Routing/AgentSourceTest.php @@ -5,7 +5,7 @@ * file that was distributed with this source code. */ -namespace Swag\PayPal\Tests\AgentCommerce\Routing; +namespace Swag\PayPal\Test\AgentCommerce\Routing; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; diff --git a/tests/AgentCommerce/SalesChannel/CheckoutRouteTest.php b/tests/AgentCommerce/SalesChannel/CheckoutRouteTest.php index 797e49391..908483bb6 100644 --- a/tests/AgentCommerce/SalesChannel/CheckoutRouteTest.php +++ b/tests/AgentCommerce/SalesChannel/CheckoutRouteTest.php @@ -5,7 +5,7 @@ * file that was distributed with this source code. */ -namespace Swag\PayPal\Tests\AgentCommerce\SalesChannel; +namespace Swag\PayPal\Test\AgentCommerce\SalesChannel; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -16,15 +16,18 @@ use Shopware\Core\Checkout\Order\Aggregate\OrderTransaction\OrderTransactionCollection; use Shopware\Core\Checkout\Order\Aggregate\OrderTransaction\OrderTransactionEntity; use Shopware\Core\Checkout\Order\OrderEntity; -use Shopware\Core\Checkout\Payment\Cart\PaymentTransactionStruct; +use Shopware\Core\Checkout\Payment\Cart\AsyncPaymentTransactionStruct; +use Shopware\Core\Checkout\Payment\PaymentException; use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Validation\DataBag\RequestDataBag; use Shopware\Core\Test\Generator; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\PayPalCart; -use Shopware\PayPalSDK\Struct\V2\Order; use Swag\PayPal\AgentCommerce\Exception\AgentException; use Swag\PayPal\AgentCommerce\SalesChannel\CheckoutRoute; +use Swag\PayPal\AgentCommerce\Struct\V1\PayPalCart; use Swag\PayPal\AgentCommerce\Util\PayPalCartTransformer; +use Swag\PayPal\Checkout\Payment\Method\AbstractPaymentMethodHandler; use Swag\PayPal\Checkout\Payment\PayPalPaymentHandler; +use Swag\PayPal\RestApi\V2\Api\Order; use Swag\PayPal\RestApi\V2\Resource\OrderResource; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; @@ -48,7 +51,7 @@ public function testCheckoutWithInvalidCartToken(): void $this->expectExceptionObject(AgentException::invalidCartId()); - $route->checkout('invalid-token', new Request(), Generator::generateSalesChannelContext()); + $route->checkout('invalid-token', new Request(), Generator::createSalesChannelContext()); } public function testCheckoutWithEmptyCart(): void @@ -57,7 +60,7 @@ public function testCheckoutWithEmptyCart(): void $cartService = $this->createMock(CartService::class); $cartService - ->expects($this->once()) + ->expects(static::once()) ->method('getCart') ->with('TOKEN') ->willReturn(new Cart('TOKEN')); @@ -72,7 +75,7 @@ public function testCheckoutWithEmptyCart(): void $this->expectExceptionObject(AgentException::cartNotFound($token)); - $route->checkout($token, new Request(), Generator::generateSalesChannelContext()); + $route->checkout($token, new Request(), Generator::createSalesChannelContext()); } public function testCheckoutWithoutTransaction(): void @@ -81,7 +84,7 @@ public function testCheckoutWithoutTransaction(): void $cartService = $this->createMock(CartService::class); $cartService - ->expects($this->once()) + ->expects(static::once()) ->method('getCart') ->with('TOKEN') ->willReturn(Generator::createCart()); @@ -93,7 +96,7 @@ public function testCheckoutWithoutTransaction(): void $orderRoute = $this->createMock(AbstractCartOrderRoute::class); $orderRoute - ->expects($this->once()) + ->expects(static::once()) ->method('order') ->willReturn($orderResponse); @@ -107,21 +110,21 @@ public function testCheckoutWithoutTransaction(): void $this->expectExceptionObject(AgentException::orderSystemError()); - $route->checkout($token, new Request(), Generator::generateSalesChannelContext()); + $route->checkout($token, new Request(), Generator::createSalesChannelContext()); } public function testCheckout(): void { $token = 'CART-TOKEN'; - $context = Generator::generateSalesChannelContext(); + $context = Generator::createSalesChannelContext(); $cart = Generator::createCart(); $request = new Request(content: \json_encode(['payment_method' => ['token' => 'PAYPAL-ORDER-ID']], \JSON_THROW_ON_ERROR)); $cartService = $this->createMock(CartService::class); $cartService - ->expects($this->once()) + ->expects(static::once()) ->method('getCart') ->with('TOKEN') ->willReturn($cart); @@ -136,7 +139,7 @@ public function testCheckout(): void $orderRoute = $this->createMock(AbstractCartOrderRoute::class); $orderRoute - ->expects($this->once()) + ->expects(static::once()) ->method('order') ->willReturn($orderResponse); @@ -145,7 +148,7 @@ public function testCheckout(): void $orderResource = $this->createMock(OrderResource::class); $orderResource - ->expects($this->once()) + ->expects(static::once()) ->method('get') ->with('PAYPAL-ORDER-ID', $context->getSalesChannelId()) ->willReturn($payPalOrder); @@ -155,17 +158,20 @@ public function testCheckout(): void $transformer = $this->createMock(PayPalCartTransformer::class); $transformer - ->expects($this->once()) + ->expects(static::once()) ->method('convertToPayPalCart') ->with($cart, $context) ->willReturn($payPalCart); + $requestDataBag = new RequestDataBag($request->request->all()); + $requestDataBag->set(AbstractPaymentMethodHandler::PAYPAL_PAYMENT_ORDER_ID_INPUT_NAME, $payPalCart->getId()); + $paymentHandler = $this->createMock(PayPalPaymentHandler::class); $paymentHandler - ->expects($this->once()) + ->expects(static::once()) ->method('pay') - ->with($request, static::equalTo(new PaymentTransactionStruct('primary-order-transaction-id')), $context->getContext(), null) - ->willReturn(null); + ->with(static::equalTo(new AsyncPaymentTransactionStruct($transaction, $order, '')), $requestDataBag, $context) + ->willThrowException(PaymentException::asyncProcessInterrupted($transaction->getId(), 'error message')); $route = new CheckoutRoute( $orderRoute, @@ -188,14 +194,14 @@ public function testCheckoutWithRedirect(): void { $token = 'CART-TOKEN'; - $context = Generator::generateSalesChannelContext(); + $context = Generator::createSalesChannelContext(); $cart = Generator::createCart(); $request = new Request(content: \json_encode(['payment_method' => ['token' => 'PAYPAL-ORDER-ID']], \JSON_THROW_ON_ERROR)); $cartService = $this->createMock(CartService::class); $cartService - ->expects($this->once()) + ->expects(static::once()) ->method('getCart') ->with('TOKEN') ->willReturn($cart); @@ -210,7 +216,7 @@ public function testCheckoutWithRedirect(): void $orderRoute = $this->createMock(AbstractCartOrderRoute::class); $orderRoute - ->expects($this->once()) + ->expects(static::once()) ->method('order') ->willReturn($orderResponse); @@ -219,7 +225,7 @@ public function testCheckoutWithRedirect(): void $orderResource = $this->createMock(OrderResource::class); $orderResource - ->expects($this->once()) + ->expects(static::once()) ->method('get') ->with('PAYPAL-ORDER-ID', $context->getSalesChannelId()) ->willReturn($payPalOrder); @@ -229,19 +235,20 @@ public function testCheckoutWithRedirect(): void $transformer = $this->createMock(PayPalCartTransformer::class); $transformer - ->expects($this->once()) + ->expects(static::once()) ->method('convertToPayPalCart') ->with($cart, $context) ->willReturn($payPalCart); - $redirect = new RedirectResponse('https://example.com/redirect-url'); + $requestDataBag = new RequestDataBag($request->request->all()); + $requestDataBag->set(AbstractPaymentMethodHandler::PAYPAL_PAYMENT_ORDER_ID_INPUT_NAME, $payPalCart->getId()); $paymentHandler = $this->createMock(PayPalPaymentHandler::class); $paymentHandler - ->expects($this->once()) + ->expects(static::once()) ->method('pay') - ->with($request, static::equalTo(new PaymentTransactionStruct('primary-order-transaction-id')), $context->getContext(), null) - ->willReturn($redirect); + ->with(static::equalTo(new AsyncPaymentTransactionStruct($transaction, $order, '')), $requestDataBag, $context) + ->willReturn(new RedirectResponse('https://example.com/redirect-url')); $route = new CheckoutRoute( $orderRoute, diff --git a/tests/AgentCommerce/SalesChannel/CreateCartRouteTest.php b/tests/AgentCommerce/SalesChannel/CreateCartRouteTest.php index f545b60c5..8e1477123 100644 --- a/tests/AgentCommerce/SalesChannel/CreateCartRouteTest.php +++ b/tests/AgentCommerce/SalesChannel/CreateCartRouteTest.php @@ -17,12 +17,12 @@ use Shopware\Core\Framework\Uuid\Uuid; use Shopware\Core\System\SalesChannel\Context\SalesChannelContextService; use Shopware\Core\System\SalesChannel\SalesChannelContext; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\PayPalCart; -use Shopware\PayPalSDK\Struct\V2\Order; use Swag\PayPal\AgentCommerce\SalesChannel\CreateCartRoute; +use Swag\PayPal\AgentCommerce\Struct\V1\PayPalCart; use Swag\PayPal\AgentCommerce\Util\PayPalCartTransformer; use Swag\PayPal\AgentCommerce\Util\ShopwareCartTransformer; use Swag\PayPal\OrdersApi\Builder\AbstractOrderBuilder; +use Swag\PayPal\RestApi\V2\Api\Order; use Swag\PayPal\RestApi\V2\Resource\OrderResource; use Symfony\Component\HttpFoundation\Request; @@ -80,12 +80,12 @@ public function testCreateCartWithCreateAndLoginCustomer(): void ->willReturn(null); $this->shopwareCartTransformer - ->expects($this->once()) + ->expects(static::once()) ->method('extractCustomerData') ->willReturn(['valid-data']); $this->registerRoute - ->expects($this->once()) + ->expects(static::once()) ->method('register'); $this->contextService @@ -95,16 +95,16 @@ public function testCreateCartWithCreateAndLoginCustomer(): void $cart = new Cart(''); $this->cartService - ->expects($this->once()) + ->expects(static::once()) ->method('createNew') ->willReturn($cart); $this->cartService - ->expects($this->once()) + ->expects(static::once()) ->method('add') ->willReturn($cart); $this->shopwareCartTransformer - ->expects($this->once()) + ->expects(static::once()) ->method('getLineItems') ->willReturn([]); @@ -112,12 +112,12 @@ public function testCreateCartWithCreateAndLoginCustomer(): void $order->setId('some-order-id'); $this->orderBuilder - ->expects($this->once()) + ->expects(static::once()) ->method('getOrderFromCart') ->willReturn($order); $this->orderResource - ->expects($this->once()) + ->expects(static::once()) ->method('create') ->willReturn($order); @@ -125,7 +125,7 @@ public function testCreateCartWithCreateAndLoginCustomer(): void $payPalCart->setValidationStatus(PayPalCart::VALIDATION_STATUS__VALID); $this->payPalCartTransformer - ->expects($this->once()) + ->expects(static::once()) ->method('convertToPayPalCart') ->willReturn($payPalCart); diff --git a/tests/AgentCommerce/SalesChannel/GetCartRouteTest.php b/tests/AgentCommerce/SalesChannel/GetCartRouteTest.php index e2a3b9be9..8e8d4211c 100644 --- a/tests/AgentCommerce/SalesChannel/GetCartRouteTest.php +++ b/tests/AgentCommerce/SalesChannel/GetCartRouteTest.php @@ -15,11 +15,11 @@ use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Uuid\Uuid; use Shopware\Core\System\SalesChannel\SalesChannelContext; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\PayPalCart; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\ValidationIssue; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\ValidationIssueCollection; use Swag\PayPal\AgentCommerce\Exception\AgentException; use Swag\PayPal\AgentCommerce\SalesChannel\GetCartRoute; +use Swag\PayPal\AgentCommerce\Struct\V1\PayPalCart; +use Swag\PayPal\AgentCommerce\Struct\V1\ValidationIssue; +use Swag\PayPal\AgentCommerce\Struct\V1\ValidationIssueCollection; use Swag\PayPal\AgentCommerce\Util\PayPalCartTransformer; /** diff --git a/tests/AgentCommerce/SalesChannel/Response/AgentCartResponseTest.php b/tests/AgentCommerce/SalesChannel/Response/AgentCartResponseTest.php index ba3112ab6..ba374dc37 100644 --- a/tests/AgentCommerce/SalesChannel/Response/AgentCartResponseTest.php +++ b/tests/AgentCommerce/SalesChannel/Response/AgentCartResponseTest.php @@ -5,13 +5,14 @@ * file that was distributed with this source code. */ -namespace Swag\PayPal\Tests\AgentCommerce\SalesChannel\Response; +namespace Swag\PayPal\Test\AgentCommerce\SalesChannel\Response; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use Shopware\Core\Framework\Log\Package; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\PayPalCart; +use Shopware\Core\Framework\Struct\ArrayStruct; use Swag\PayPal\AgentCommerce\SalesChannel\Response\AgentCartResponse; +use Swag\PayPal\AgentCommerce\Struct\V1\PayPalCart; /** * @internal @@ -26,7 +27,9 @@ public function testConstruct(): void $cart->setId('test-token'); $response = new AgentCartResponse($cart); + $responseObject = $response->getObject(); - static::assertSame(['id' => 'test-token'], $response->getObject()->all()); + static::assertInstanceOf(ArrayStruct::class, $responseObject); + static::assertSame(['id' => 'test-token'], $responseObject->all()); } } diff --git a/tests/AgentCommerce/SalesChannel/Response/AgentResponseExceptionSubscriberTest.php b/tests/AgentCommerce/SalesChannel/Response/AgentResponseExceptionSubscriberTest.php index caa9aa5ef..5a3c6df2e 100644 --- a/tests/AgentCommerce/SalesChannel/Response/AgentResponseExceptionSubscriberTest.php +++ b/tests/AgentCommerce/SalesChannel/Response/AgentResponseExceptionSubscriberTest.php @@ -5,26 +5,24 @@ * file that was distributed with this source code. */ -namespace Swag\PayPal\Tests\AgentCommerce\SalesChannel\Response; +namespace Swag\PayPal\Test\AgentCommerce\SalesChannel\Response; use Monolog\Logger; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use Shopware\Core\Checkout\Payment\PaymentException; use Shopware\Core\Framework\Api\Context\SystemSource; use Shopware\Core\Framework\Context; use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Routing\RouteScopeRegistry; +use Shopware\Core\Framework\Uuid\Uuid; use Shopware\Core\PlatformRequest; -use Shopware\Core\Test\Integration\PaymentHandler\TestPaymentHandler; -use Shopware\Core\Test\Stub\Symfony\StubKernel; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\AgentError; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\AgentErrorDetail; -use Shopware\PayPalSDK\Struct\Struct; use Swag\PayPal\AgentCommerce\Exception\AgentException; use Swag\PayPal\AgentCommerce\Routing\AgentRouteScope; use Swag\PayPal\AgentCommerce\Routing\AgentSource; use Swag\PayPal\AgentCommerce\SalesChannel\Response\AgentResponseExceptionSubscriber; -use Swag\PayPal\Checkout\CheckoutException; +use Swag\PayPal\AgentCommerce\Struct\V1\AgentError; +use Swag\PayPal\AgentCommerce\Struct\V1\AgentErrorDetail; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Event\ExceptionEvent; use Symfony\Component\HttpKernel\HttpKernelInterface; @@ -53,7 +51,7 @@ public function testOnKernelExceptionWithoutContext(): void { $request = new Request(); $request->attributes->set(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, [AgentRouteScope::ID]); - $event = self::createEvent($request, new \Exception('Test exception')); + $event = $this->createEvent($request, new \Exception('Test exception')); $subscriber = new AgentResponseExceptionSubscriber(new Logger('test'), new RouteScopeRegistry([new AgentRouteScope()])); $subscriber->onKernelException($event); @@ -64,7 +62,7 @@ public function testOnKernelExceptionWithoutContext(): void static::assertNotFalse($response->getContent()); $content = \json_decode($response->getContent(), true, flags: \JSON_THROW_ON_ERROR); - $error = Struct::from(AgentError::class, $content); + $error = (new AgentError())->assign($content); static::assertNull($error->getDebugId()); } @@ -74,7 +72,7 @@ public function testOnKernelExceptionWithNonContextObject(): void $request = new Request(); $request->attributes->set(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, [AgentRouteScope::ID]); $request->attributes->set(PlatformRequest::ATTRIBUTE_CONTEXT_OBJECT, new \stdClass()); - $event = self::createEvent($request, new \Exception('Test exception')); + $event = $this->createEvent($request, new \Exception('Test exception')); $subscriber = new AgentResponseExceptionSubscriber(new Logger('test'), new RouteScopeRegistry([new AgentRouteScope()])); $subscriber->onKernelException($event); @@ -85,7 +83,7 @@ public function testOnKernelExceptionWithNonContextObject(): void static::assertNotFalse($response->getContent()); $content = \json_decode($response->getContent(), true, flags: \JSON_THROW_ON_ERROR); - $error = Struct::from(AgentError::class, $content); + $error = (new AgentError())->assign($content); static::assertNull($error->getDebugId()); } @@ -95,7 +93,7 @@ public function testOnKernelExceptionWithNonPayPalAgentSource(): void $request = new Request(); $context = Context::createDefaultContext(new SystemSource()); $request->attributes->set(PlatformRequest::ATTRIBUTE_CONTEXT_OBJECT, $context); - $event = self::createEvent($request, new \Exception('Test exception')); + $event = $this->createEvent($request, new \Exception('Test exception')); $subscriber = new AgentResponseExceptionSubscriber(new Logger('test'), new RouteScopeRegistry([new AgentRouteScope()])); $subscriber->onKernelException($event); @@ -111,7 +109,7 @@ public function testOnKernelExceptionPayPalAgentException(): void $context = Context::createDefaultContext($source); $request->attributes->set(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, [AgentRouteScope::ID]); $request->attributes->set(PlatformRequest::ATTRIBUTE_CONTEXT_OBJECT, $context); - $event = self::createEvent($request, AgentException::requiredFieldsMissing('foo', 'bar')); + $event = $this->createEvent($request, AgentException::requiredFieldsMissing('foo', 'bar')); $subscriber = new AgentResponseExceptionSubscriber(new Logger('test'), new RouteScopeRegistry([new AgentRouteScope()])); $subscriber->onKernelException($event); @@ -122,9 +120,8 @@ public function testOnKernelExceptionPayPalAgentException(): void static::assertNotFalse($response->getContent()); $content = \json_decode($response->getContent(), true, flags: \JSON_THROW_ON_ERROR); - $error = Struct::from(AgentError::class, $content); + $error = (new AgentError())->assign($content); - static::assertInstanceOf(AgentError::class, $error); static::assertSame('INVALID_REQUEST', $error->getName()); static::assertSame('Required field \'foo, bar\' is missing', $error->getMessage()); static::assertSame(400, $error->getCode()); @@ -153,7 +150,7 @@ public function testOnKernelExceptionHttpException(): void $context = Context::createDefaultContext($source); $request->attributes->set(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, [AgentRouteScope::ID]); $request->attributes->set(PlatformRequest::ATTRIBUTE_CONTEXT_OBJECT, $context); - $event = self::createEvent($request, CheckoutException::preparedOrderRequired(TestPaymentHandler::class)); + $event = $this->createEvent($request, PaymentException::asyncProcessInterrupted(Uuid::randomHex(), 'Error message')); $subscriber = new AgentResponseExceptionSubscriber(new Logger('test'), new RouteScopeRegistry([new AgentRouteScope()])); $subscriber->onKernelException($event); @@ -164,11 +161,9 @@ public function testOnKernelExceptionHttpException(): void static::assertNotFalse($response->getContent()); $content = \json_decode($response->getContent(), true, flags: \JSON_THROW_ON_ERROR); - $error = Struct::from(AgentError::class, $content); + $error = (new AgentError())->assign($content); - static::assertInstanceOf(AgentError::class, $error); - static::assertSame('PREPARED_ORDER_REQUIRED', $error->getName()); - static::assertSame('PayPal Order ID does not exist in the request. The payment method Shopware\Core\Test\Integration\PaymentHandler\TestPaymentHandler requires a prepared PayPal order.', $error->getMessage()); + static::assertSame('CHECKOUT__ASYNC_PAYMENT_PROCESS_INTERRUPTED', $error->getName()); static::assertSame(400, $error->getCode()); static::assertSame('debug-id', $error->getDebugId()); } @@ -181,7 +176,7 @@ public function testOnKernelExceptionGenericThrowable(): void $context = Context::createDefaultContext($source); $request->attributes->set(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, [AgentRouteScope::ID]); $request->attributes->set(PlatformRequest::ATTRIBUTE_CONTEXT_OBJECT, $context); - $event = self::createEvent($request, new \Exception('Generic error')); + $event = $this->createEvent($request, new \Exception('Generic error')); $subscriber = new AgentResponseExceptionSubscriber(new Logger('test'), new RouteScopeRegistry([new AgentRouteScope()])); $subscriber->onKernelException($event); @@ -192,17 +187,16 @@ public function testOnKernelExceptionGenericThrowable(): void static::assertNotFalse($response->getContent()); $content = \json_decode($response->getContent(), true, flags: \JSON_THROW_ON_ERROR); - $error = Struct::from(AgentError::class, $content); + $error = (new AgentError())->assign($content); - static::assertInstanceOf(AgentError::class, $error); static::assertSame('UNKNOWN_ERROR', $error->getName()); static::assertSame('Generic error', $error->getMessage()); static::assertSame(500, $error->getCode()); static::assertSame('debug-id', $error->getDebugId()); } - private static function createEvent(Request $request, \Throwable $e): ExceptionEvent + private function createEvent(Request $request, \Throwable $e): ExceptionEvent { - return new ExceptionEvent(new StubKernel(), $request, HttpKernelInterface::MAIN_REQUEST, $e); + return new ExceptionEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST, $e); } } diff --git a/tests/AgentCommerce/SalesChannel/UpdateCartRouteTest.php b/tests/AgentCommerce/SalesChannel/UpdateCartRouteTest.php index fee8aa5f9..13cc84e69 100644 --- a/tests/AgentCommerce/SalesChannel/UpdateCartRouteTest.php +++ b/tests/AgentCommerce/SalesChannel/UpdateCartRouteTest.php @@ -13,17 +13,18 @@ use Shopware\Core\Checkout\Customer\CustomerEntity; use Shopware\Core\Checkout\Shipping\ShippingMethodEntity; use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository; +use Shopware\Core\Framework\Struct\ArrayStruct; use Shopware\Core\Framework\Uuid\Uuid; use Shopware\Core\Framework\Validation\Exception\ConstraintViolationException; use Shopware\Core\System\SalesChannel\Context\SalesChannelContextService; use Shopware\Core\System\SalesChannel\ContextTokenResponse; use Shopware\Core\System\SalesChannel\SalesChannel\ContextSwitchRoute; use Shopware\Core\System\SalesChannel\SalesChannelContext; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\PayPalCart; use Swag\PayPal\AgentCommerce\Exception\AgentException; use Swag\PayPal\AgentCommerce\SalesChannel\CreateCartRoute; use Swag\PayPal\AgentCommerce\SalesChannel\Response\AgentCartResponse; use Swag\PayPal\AgentCommerce\SalesChannel\UpdateCartRoute; +use Swag\PayPal\AgentCommerce\Struct\V1\PayPalCart; use Swag\PayPal\AgentCommerce\Util\ShopwareCartTransformer; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Validator\ConstraintViolationList; @@ -94,12 +95,12 @@ public function testDeleteCustomer(): void ->willReturn($customer); $this->customerRepository - ->expects($this->once()) + ->expects(static::once()) ->method('delete') ->with([['id' => $customer->getId()]]); $this->cartService - ->expects($this->once()) + ->expects(static::once()) ->method('deleteCart'); $payPalCart = new PayPalCart(); @@ -107,14 +108,16 @@ public function testDeleteCustomer(): void $createResponse = new AgentCartResponse($payPalCart); $this->createCartRoute - ->expects($this->once()) + ->expects(static::once()) ->method('createCart') ->willReturn($createResponse); $response = $this->updateCartRoute->updateCart('CART-11111111111111111111111111111111', new Request(content: $content), $this->salesChannelContext); + $responseObject = $response->getObject(); static::assertSame(200, $response->getStatusCode()); - static::assertSame(PayPalCart::STATUS__READY, $response->getObject()->offsetGet('validation_status')); + static::assertInstanceOf(ArrayStruct::class, $responseObject); + static::assertSame(PayPalCart::STATUS__READY, $responseObject->offsetGet('validation_status')); } public function testUpsertAddresses(): void @@ -132,11 +135,11 @@ public function testUpsertAddresses(): void ->willReturn($customer); $this->customerAddressRepository - ->expects($this->never()) + ->expects(static::never()) ->method('delete'); $this->cartService - ->expects($this->once()) + ->expects(static::once()) ->method('deleteCart'); $customerData = [ @@ -163,15 +166,15 @@ public function testUpsertAddresses(): void ]; $this->customerRepository - ->expects($this->never()) + ->expects(static::never()) ->method('delete'); $this->customerRepository - ->expects($this->once()) + ->expects(static::once()) ->method('update') ->with([$upsertData]); $this->shopwareCartTransformer - ->expects($this->once()) + ->expects(static::once()) ->method('extractCustomerData') ->willReturn($customerData); @@ -180,14 +183,16 @@ public function testUpsertAddresses(): void $createResponse = new AgentCartResponse($payPalCart); $this->createCartRoute - ->expects($this->once()) + ->expects(static::once()) ->method('createCart') ->willReturn($createResponse); $response = $this->updateCartRoute->updateCart('CART-11111111111111111111111111111111', new Request(content: $content), $this->salesChannelContext); + $responseObject = $response->getObject(); static::assertSame(200, $response->getStatusCode()); - static::assertSame(PayPalCart::STATUS__READY, $response->getObject()->offsetGet('validation_status')); + static::assertInstanceOf(ArrayStruct::class, $responseObject); + static::assertSame(PayPalCart::STATUS__READY, $responseObject->offsetGet('validation_status')); } public function testDeleteBillingAddress(): void @@ -205,12 +210,12 @@ public function testDeleteBillingAddress(): void ->willReturn($customer); $this->customerAddressRepository - ->expects($this->once()) + ->expects(static::once()) ->method('delete') ->with([['id' => $customer->getDefaultBillingAddressId()]]); $this->cartService - ->expects($this->once()) + ->expects(static::once()) ->method('deleteCart'); $customerData = [ @@ -231,10 +236,10 @@ public function testDeleteBillingAddress(): void ]; $this->customerRepository - ->expects($this->never()) + ->expects(static::never()) ->method('delete'); $this->customerRepository - ->expects($this->once()) + ->expects(static::once()) ->method('update') ->with([$upsertData]); @@ -247,14 +252,16 @@ public function testDeleteBillingAddress(): void $createResponse = new AgentCartResponse($payPalCart); $this->createCartRoute - ->expects($this->once()) + ->expects(static::once()) ->method('createCart') ->willReturn($createResponse); $response = $this->updateCartRoute->updateCart('CART-11111111111111111111111111111111', new Request(content: $content), $this->salesChannelContext); + $responseObject = $response->getObject(); static::assertSame(200, $response->getStatusCode()); - static::assertSame(PayPalCart::STATUS__READY, $response->getObject()->offsetGet('validation_status')); + static::assertInstanceOf(ArrayStruct::class, $responseObject); + static::assertSame(PayPalCart::STATUS__READY, $responseObject->offsetGet('validation_status')); } public function testChangeShippingMethod(): void @@ -267,7 +274,7 @@ public function testChangeShippingMethod(): void ->willReturn(null); $this->cartService - ->expects($this->once()) + ->expects(static::once()) ->method('deleteCart'); $payPalCart = new PayPalCart(); @@ -275,19 +282,21 @@ public function testChangeShippingMethod(): void $createResponse = new AgentCartResponse($payPalCart); $this->createCartRoute - ->expects($this->once()) + ->expects(static::once()) ->method('createCart') ->willReturn($createResponse); $this->contextSwitchRoute - ->expects($this->once()) + ->expects(static::once()) ->method('switchContext') ->willReturn(new ContextTokenResponse('some-token', 'some-url')); $response = $this->updateCartRoute->updateCart('CART-11111111111111111111111111111111', new Request(content: $content), $this->salesChannelContext); + $responseObject = $response->getObject(); static::assertSame(200, $response->getStatusCode()); - static::assertSame(PayPalCart::STATUS__READY, $response->getObject()->offsetGet('validation_status')); + static::assertInstanceOf(ArrayStruct::class, $responseObject); + static::assertSame(PayPalCart::STATUS__READY, $responseObject->offsetGet('validation_status')); } public function testShippingMethodError(): void @@ -302,22 +311,24 @@ public function testShippingMethodError(): void ->willReturn(null); $this->cartService - ->expects($this->once()) + ->expects(static::once()) ->method('deleteCart'); $this->createCartRoute - ->expects($this->never()) + ->expects(static::never()) ->method('createCart'); $this->contextSwitchRoute - ->expects($this->once()) + ->expects(static::once()) ->method('switchContext') ->willThrowException(new ConstraintViolationException(new ConstraintViolationList(), [])); $response = $this->updateCartRoute->updateCart('CART-11111111111111111111111111111111', new Request(content: $content), $this->salesChannelContext); + $responseObject = $response->getObject(); static::assertSame(200, $response->getStatusCode()); - static::assertSame(PayPalCart::STATUS__READY, $response->getObject()->offsetGet('validation_status')); + static::assertInstanceOf(ArrayStruct::class, $responseObject); + static::assertSame(PayPalCart::STATUS__READY, $responseObject->offsetGet('validation_status')); } public function testRightShippingMethodSelected(): void @@ -338,7 +349,7 @@ public function testRightShippingMethodSelected(): void ->willReturn($shippingMethod); $this->cartService - ->expects($this->once()) + ->expects(static::once()) ->method('deleteCart'); $payPalCart = new PayPalCart(); @@ -346,18 +357,20 @@ public function testRightShippingMethodSelected(): void $createResponse = new AgentCartResponse($payPalCart); $this->createCartRoute - ->expects($this->once()) + ->expects(static::once()) ->method('createCart') ->willReturn($createResponse); $this->contextSwitchRoute - ->expects($this->never()) + ->expects(static::never()) ->method('switchContext'); $response = $this->updateCartRoute->updateCart('CART-11111111111111111111111111111111', new Request(content: $content), $this->salesChannelContext); + $responseObject = $response->getObject(); static::assertSame(200, $response->getStatusCode()); - static::assertSame(PayPalCart::STATUS__READY, $response->getObject()->offsetGet('validation_status')); + static::assertInstanceOf(ArrayStruct::class, $responseObject); + static::assertSame(PayPalCart::STATUS__READY, $responseObject->offsetGet('validation_status')); } private static function createItems(): array diff --git a/tests/AgentCommerce/Subscriber/ProductFilterSubscriberTest.php b/tests/AgentCommerce/Subscriber/ProductFilterSubscriberTest.php index e1999c2cd..67e652e06 100644 --- a/tests/AgentCommerce/Subscriber/ProductFilterSubscriberTest.php +++ b/tests/AgentCommerce/Subscriber/ProductFilterSubscriberTest.php @@ -32,7 +32,7 @@ public function testNoAgentSource(): void $salesChannelContext = $this->createMock(SalesChannelContext::class); $salesChannelContext ->method('getContext') - ->willReturn(Context::createCLIContext()); + ->willReturn(Context::createDefaultContext()); $criteria = new Criteria(); $event = new ProductGatewayCriteriaEvent([], $criteria, $salesChannelContext); @@ -50,7 +50,7 @@ public function testProductAddCriteria(): void $salesChannelContext = $this->createMock(SalesChannelContext::class); $salesChannelContext ->method('getContext') - ->willReturn(Context::createCLIContext($source)); + ->willReturn(Context::createDefaultContext($source)); $criteria = new Criteria(); $event = new ProductGatewayCriteriaEvent([], $criteria, $salesChannelContext); @@ -69,12 +69,12 @@ public function testProductAddCriteriaAdminSalesChannelSource(): void { $originalSource = new AgentSource('merchantId', new \DateTime(), new \DateTime(), [], Uuid::randomHex()); $originalSource->setStreamId(Uuid::randomHex()); - $source = new AdminSalesChannelApiSource(Uuid::randomHex(), Context::createCLIContext($originalSource)); + $source = new AdminSalesChannelApiSource(Uuid::randomHex(), Context::createDefaultContext($originalSource)); $salesChannelContext = $this->createMock(SalesChannelContext::class); $salesChannelContext ->method('getContext') - ->willReturn(Context::createCLIContext($source)); + ->willReturn(Context::createDefaultContext($source)); $criteria = new Criteria(); $event = new ProductGatewayCriteriaEvent([], $criteria, $salesChannelContext); diff --git a/tests/AgentCommerce/Subscriber/WebhookSubscriberTest.php b/tests/AgentCommerce/Subscriber/WebhookSubscriberTest.php index 6076df34c..cbde8080d 100644 --- a/tests/AgentCommerce/Subscriber/WebhookSubscriberTest.php +++ b/tests/AgentCommerce/Subscriber/WebhookSubscriberTest.php @@ -63,13 +63,13 @@ public function testTest(): void $event = new EntityWrittenEvent( SalesChannelDefinition::ENTITY_NAME, [$deleteResult, $noPayloadResult, $activateResult, $deactivateResult], - Context::createCLIContext(new AdminApiSource(Uuid::randomHex())), + Context::createDefaultContext(new AdminApiSource(Uuid::randomHex())), [] ); $salesChannelRepository = $this->createMock(EntityRepository::class); $salesChannelRepository - ->expects($this->once()) + ->expects(static::once()) ->method('searchIds') ->willReturnCallback(static function (Criteria $criteria) use ($deleteId, $activateId, $deactiveId) { static::assertSame([$deleteId, $activateId, $deactiveId], $criteria->getIds()); @@ -79,24 +79,24 @@ public function testTest(): void ['primaryKey' => $deactiveId, 'data' => []], ]; - return new IdSearchResult(2, $data, $criteria, Context::createCLIContext()); + return new IdSearchResult(2, $data, $criteria, Context::createDefaultContext()); }); $webhookResult = new HoneyWebhookResult(true, 'success message', null); $webhook = $this->createMock(HoneyWebhookService::class); $webhook - ->expects($this->once()) + ->expects(static::once()) ->method('register') ->with($activateId) ->willReturn($webhookResult); $webhook - ->expects($this->exactly(2)) + ->expects(static::exactly(2)) ->method('deregister') ->willReturn($webhookResult); $notificationRepository = $this->createMock(EntityRepository::class); $notificationRepository - ->expects($this->exactly(3)) + ->expects(static::exactly(3)) ->method('create'); $subscriber = new WebhookSubscriber( @@ -112,20 +112,20 @@ public function testEmptyWriteResult(): void { $salesChannelRepository = $this->createMock(EntityRepository::class); $salesChannelRepository - ->expects($this->never()) + ->expects(static::never()) ->method('searchIds'); $webhook = $this->createMock(HoneyWebhookService::class); $webhook - ->expects($this->never()) + ->expects(static::never()) ->method('register'); $webhook - ->expects($this->never()) + ->expects(static::never()) ->method('deregister'); $notificationRepository = $this->createMock(EntityRepository::class); $notificationRepository - ->expects($this->never()) + ->expects(static::never()) ->method('create'); $subscriber = new WebhookSubscriber( @@ -134,7 +134,7 @@ public function testEmptyWriteResult(): void $notificationRepository, ); - $event = new EntityWrittenEvent(SalesChannelDefinition::ENTITY_NAME, [], Context::createCLIContext(), []); + $event = new EntityWrittenEvent(SalesChannelDefinition::ENTITY_NAME, [], Context::createDefaultContext(), []); $subscriber->handleWebhookLifecycle($event); } diff --git a/tests/AgentCommerce/Util/AgentDebugIDProcessorTest.php b/tests/AgentCommerce/Util/AgentDebugIDProcessorTest.php index 1601b88f7..81c849561 100644 --- a/tests/AgentCommerce/Util/AgentDebugIDProcessorTest.php +++ b/tests/AgentCommerce/Util/AgentDebugIDProcessorTest.php @@ -5,7 +5,7 @@ * file that was distributed with this source code. */ -namespace Swag\PayPal\Tests\AgentCommerce\Util; +namespace Swag\PayPal\Test\AgentCommerce\Util; use Monolog\Level; use Monolog\LogRecord; diff --git a/tests/AgentCommerce/Util/FaviconLoaderTest.php b/tests/AgentCommerce/Util/FaviconLoaderTest.php index 1550831cb..26234c0ad 100644 --- a/tests/AgentCommerce/Util/FaviconLoaderTest.php +++ b/tests/AgentCommerce/Util/FaviconLoaderTest.php @@ -5,7 +5,7 @@ * file that was distributed with this source code. */ -namespace Swag\PayPal\Tests\AgentCommerce\Util; +namespace Swag\PayPal\Test\AgentCommerce\Util; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -33,7 +33,7 @@ public function testThemeIdNotFound(): void $themeProviderMock = $this->createMock(AbstractAvailableThemeProvider::class); $themeProviderMock - ->expects($this->once()) + ->expects(static::once()) ->method('load') ->willReturn([]); @@ -43,18 +43,18 @@ public function testThemeIdNotFound(): void $this->createMock(SalesChannelContextService::class), ); - $faviconLoader->loadFaviconLink(Uuid::randomHex(), Context::createCLIContext()); + $faviconLoader->loadFaviconLink(Uuid::randomHex(), Context::createDefaultContext()); } public function testLoadFaviconLink(): void { $themeId = Uuid::randomHex(); $salesChannelId = Uuid::randomHex(); - $context = Context::createCLIContext(); + $context = Context::createDefaultContext(); $themeProviderMock = $this->createMock(AbstractAvailableThemeProvider::class); $themeProviderMock - ->expects($this->once()) + ->expects(static::once()) ->method('load') ->willReturn([$salesChannelId => $themeId]); @@ -62,13 +62,13 @@ public function testLoadFaviconLink(): void $contextServiceMock = $this->createMock(SalesChannelContextService::class); $contextServiceMock - ->expects($this->once()) + ->expects(static::once()) ->method('get') ->willReturn($salesChannelMock); $configLoaderMock = $this->createMock(AbstractResolvedConfigLoader::class); $configLoaderMock - ->expects($this->once()) + ->expects(static::once()) ->method('load') ->with($themeId, $salesChannelMock) ->willReturn(['sw-logo-favicon' => 'https://example.com/favicon.ico']); @@ -88,11 +88,11 @@ public function testLoadFaviconEmptyLink(): void { $themeId = Uuid::randomHex(); $salesChannelId = Uuid::randomHex(); - $context = Context::createCLIContext(); + $context = Context::createDefaultContext(); $themeProviderMock = $this->createMock(AbstractAvailableThemeProvider::class); $themeProviderMock - ->expects($this->once()) + ->expects(static::once()) ->method('load') ->willReturn([$salesChannelId => $themeId]); @@ -100,13 +100,13 @@ public function testLoadFaviconEmptyLink(): void $contextServiceMock = $this->createMock(SalesChannelContextService::class); $contextServiceMock - ->expects($this->once()) + ->expects(static::once()) ->method('get') ->willReturn($salesChannelMock); $configLoaderMock = $this->createMock(AbstractResolvedConfigLoader::class); $configLoaderMock - ->expects($this->once()) + ->expects(static::once()) ->method('load') ->with($themeId, $salesChannelMock) ->willReturn([]); diff --git a/tests/AgentCommerce/Util/PayPalCartFactoryTest.php b/tests/AgentCommerce/Util/PayPalCartFactoryTest.php index 2698eb479..7e7154093 100644 --- a/tests/AgentCommerce/Util/PayPalCartFactoryTest.php +++ b/tests/AgentCommerce/Util/PayPalCartFactoryTest.php @@ -5,7 +5,7 @@ * file that was distributed with this source code. */ -namespace Swag\PayPal\Tests\AgentCommerce\Util; +namespace Swag\PayPal\Test\AgentCommerce\Util; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; diff --git a/tests/AgentCommerce/Util/PayPalCartTransformerTest.php b/tests/AgentCommerce/Util/PayPalCartTransformerTest.php index 636dc2d83..6b336610c 100644 --- a/tests/AgentCommerce/Util/PayPalCartTransformerTest.php +++ b/tests/AgentCommerce/Util/PayPalCartTransformerTest.php @@ -5,7 +5,7 @@ * file that was distributed with this source code. */ -namespace Swag\PayPal\Tests\AgentCommerce\Util; +namespace Swag\PayPal\Test\AgentCommerce\Util; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -43,23 +43,26 @@ use Shopware\Core\System\Country\CountryEntity; use Shopware\Core\System\Currency\CurrencyEntity; use Shopware\Core\System\DeliveryTime\DeliveryTimeEntity; +use Shopware\Core\System\Locale\LocaleCollection; +use Shopware\Core\System\Locale\LocaleDefinition; +use Shopware\Core\System\Locale\LocaleEntity; use Shopware\Core\System\SalesChannel\Context\LanguageInfo; use Shopware\Core\System\SalesChannel\SalesChannelContext; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\Address; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\AppliedCoupon; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\AppliedCouponCollection; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\BillingAddress; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\CartItem; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\CartItemCollection; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\CartTotals; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\Customer; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\Money; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\PayPalCart; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\ShippingAddress; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\ShippingOption; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\ShippingOptionCollection; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\ValidationIssue; use Swag\PayPal\AgentCommerce\Exception\AgentException; +use Swag\PayPal\AgentCommerce\Struct\V1\Address; +use Swag\PayPal\AgentCommerce\Struct\V1\AppliedCoupon; +use Swag\PayPal\AgentCommerce\Struct\V1\AppliedCouponCollection; +use Swag\PayPal\AgentCommerce\Struct\V1\BillingAddress; +use Swag\PayPal\AgentCommerce\Struct\V1\CartItem; +use Swag\PayPal\AgentCommerce\Struct\V1\CartItemCollection; +use Swag\PayPal\AgentCommerce\Struct\V1\CartTotals; +use Swag\PayPal\AgentCommerce\Struct\V1\Customer; +use Swag\PayPal\AgentCommerce\Struct\V1\Money; +use Swag\PayPal\AgentCommerce\Struct\V1\PayPalCart; +use Swag\PayPal\AgentCommerce\Struct\V1\ShippingAddress; +use Swag\PayPal\AgentCommerce\Struct\V1\ShippingOption; +use Swag\PayPal\AgentCommerce\Struct\V1\ShippingOptionCollection; +use Swag\PayPal\AgentCommerce\Struct\V1\ValidationIssue; use Swag\PayPal\AgentCommerce\Util\PayPalCartTransformer; use Swag\PayPal\AgentCommerce\Validation\ValidationIssues; @@ -77,6 +80,7 @@ public function testConvertToPayPalCart(): void $this->createMock(EntityRepository::class), $this->createMock(AbstractShippingMethodRoute::class), $this->createMock(ValidationIssues::class), + $this->createMock(EntityRepository::class), ); $currency = new CurrencyEntity(); @@ -125,6 +129,7 @@ public function testConvertToCartItems(): void $this->createMock(EntityRepository::class), $this->createMock(AbstractShippingMethodRoute::class), $this->createMock(ValidationIssues::class), + $this->createMock(EntityRepository::class), ); $currency = new CurrencyEntity(); @@ -205,12 +210,12 @@ public function testConvertToAvailableShippingMethods(): void new ShippingMethodCollection([$shippingMethod1, $shippingMethod2, $shippingMethod3]), null, new Criteria(), - Context::createCLIContext() + Context::createDefaultContext() ); $shippingRouteMock = $this->createMock(AbstractShippingMethodRoute::class); $shippingRouteMock - ->expects($this->once()) + ->expects(static::once()) ->method('load') ->willReturn(new ShippingMethodRouteResponse($result)); @@ -219,6 +224,7 @@ public function testConvertToAvailableShippingMethods(): void $this->createMock(EntityRepository::class), $shippingRouteMock, $this->createMock(ValidationIssues::class), + $this->createMock(EntityRepository::class), ); $currency = new CurrencyEntity(); @@ -233,7 +239,7 @@ public function testConvertToAvailableShippingMethods(): void new DeliveryPositionCollection(), new DeliveryDate(new \DateTime(), new \DateTime()), new ShippingMethodEntity(), - new ShippingLocation(new CountryEntity()), + new ShippingLocation(new CountryEntity(), null, null), new CalculatedPrice(10, 10, new CalculatedTaxCollection(), new TaxRuleCollection()) ); @@ -287,6 +293,7 @@ public function testConvertNullCustomer(): void $this->createMock(EntityRepository::class), $this->createMock(AbstractShippingMethodRoute::class), $this->createMock(ValidationIssues::class), + $this->createMock(EntityRepository::class), ); static::assertNull($transformer->convertCustomer(null)); @@ -299,6 +306,7 @@ public function testConvertCustomerNoPhoneNumber(): void $this->createMock(EntityRepository::class), $this->createMock(AbstractShippingMethodRoute::class), $this->createMock(ValidationIssues::class), + $this->createMock(EntityRepository::class), ); $converted = $transformer->convertCustomer(self::createCustomer(null)); @@ -316,6 +324,7 @@ public function testConvertCustomerValidPhoneNumber(): void $this->createMock(EntityRepository::class), $this->createMock(AbstractShippingMethodRoute::class), $this->createMock(ValidationIssues::class), + $this->createMock(EntityRepository::class), ); $converted = $transformer->convertCustomer(self::createCustomer('+12 12345-67890')); @@ -336,6 +345,7 @@ public function testConvertCustomerInvalidPhoneNumber(): void $this->createMock(EntityRepository::class), $this->createMock(AbstractShippingMethodRoute::class), $this->createMock(ValidationIssues::class), + $this->createMock(EntityRepository::class), ); $converted = $transformer->convertCustomer(self::createCustomer('1234567890')); @@ -353,6 +363,7 @@ public function testCreateTotals(): void $this->createMock(EntityRepository::class), $this->createMock(AbstractShippingMethodRoute::class), $this->createMock(ValidationIssues::class), + $this->createMock(EntityRepository::class), ); $currency = new CurrencyEntity(); @@ -384,12 +395,13 @@ public function testConvertAddressNoIsoFound(): void $this->createMock(EntityRepository::class), $this->createMock(AbstractShippingMethodRoute::class), $this->createMock(ValidationIssues::class), + $this->createMock(EntityRepository::class), ); $address = new CustomerAddressEntity(); $address->setCountryId(Uuid::randomHex()); - $transformer->convertAddress($address, ShippingAddress::class, Context::createCLIContext()); + $transformer->convertAddress($address, ShippingAddress::class, Context::createDefaultContext()); } public function testConvertNullAddress(): void @@ -399,9 +411,10 @@ public function testConvertNullAddress(): void $this->createMock(EntityRepository::class), $this->createMock(AbstractShippingMethodRoute::class), $this->createMock(ValidationIssues::class), + $this->createMock(EntityRepository::class), ); - static::assertNull($transformer->convertAddress(null, ShippingAddress::class, Context::createCLIContext())); + static::assertNull($transformer->convertAddress(null, ShippingAddress::class, Context::createDefaultContext())); } public function testConvertAddress(): void @@ -416,7 +429,7 @@ public function testConvertAddress(): void new EntityCollection([$entity]), null, new Criteria(), - Context::createCLIContext() + Context::createDefaultContext() ); $repository = $this->createMock(EntityRepository::class); @@ -429,6 +442,7 @@ public function testConvertAddress(): void $repository, $this->createMock(AbstractShippingMethodRoute::class), $this->createMock(ValidationIssues::class), + $this->createMock(EntityRepository::class), ); $address = new CustomerAddressEntity(); @@ -437,8 +451,8 @@ public function testConvertAddress(): void $address->setStreet('Mainstreet 1'); $address->setCity('City 1'); - $shippingAddress = $transformer->convertAddress($address, ShippingAddress::class, Context::createCLIContext()); - $billingAddress = $transformer->convertAddress($address, BillingAddress::class, Context::createCLIContext()); + $shippingAddress = $transformer->convertAddress($address, ShippingAddress::class, Context::createDefaultContext()); + $billingAddress = $transformer->convertAddress($address, BillingAddress::class, Context::createDefaultContext()); static::assertInstanceOf(ShippingAddress::class, $shippingAddress); static::assertInstanceOf(BillingAddress::class, $billingAddress); @@ -460,6 +474,7 @@ public function testConvertToValidationIssuesNoIssues(): void $this->createMock(EntityRepository::class), $this->createMock(AbstractShippingMethodRoute::class), $this->createMock(ValidationIssues::class), + $this->createMock(EntityRepository::class), ); ['validationIssues' => $issues, 'status' => $status] = $transformer->convertToValidationIssues( @@ -505,11 +520,13 @@ public function testConvertToValidationIssues(): void return $issue; }); + $localeRepository = $this->createMock(EntityRepository::class); $transformer = new PayPalCartTransformer( $this->createMock(EntityRepository::class), $this->createMock(EntityRepository::class), $this->createMock(AbstractShippingMethodRoute::class), $validationIssueMock, + $localeRepository, ); $context = $this->createMock(SalesChannelContext::class); @@ -518,10 +535,27 @@ public function testConvertToValidationIssues(): void ->willReturn(new CurrencyEntity()); $context ->method('getContext') - ->willReturn(Context::createCLIContext()); - $context - ->method('getLanguageInfo') - ->willReturn(new LanguageInfo('Test', 'en-GB')); + ->willReturn(Context::createDefaultContext()); + + if (\method_exists($context, 'getLanguageInfo') && \class_exists(LanguageInfo::class)) { + $context + ->method('getLanguageInfo') + ->willReturn(new LanguageInfo('Test', 'en-GB')); + } else { + $locale = new LocaleEntity(); + $locale->setCode('en-GB'); + + $localeRepository + ->method('search') + ->willReturn(new EntitySearchResult( + LocaleDefinition::ENTITY_NAME, + 1, + new LocaleCollection([$locale]), + null, + new Criteria(), + Context::createDefaultContext() + )); + } $outOfStockId = Uuid::randomHex(); $priceChangedId = Uuid::randomHex(); @@ -551,11 +585,14 @@ public function testConvertToValidationIssues(): void $invalidReferenceUuid = new LineItem(Uuid::randomHex(), 'product', 'some-string', 1); $referenceIdNull = new LineItem(Uuid::randomHex(), 'product', null, 1); + $lineItem = new LineItem(Uuid::randomHex(), 'promotion'); + $lineItem->setLabel('Promotion Label'); + $cart = new Cart(Uuid::randomHex()); $cart->addLineItems(new LineItemCollection([$outOfStock, $priceChanged, $validItem, $validItemWithInitPrice, $validItemNoInitPrice, $invalidReferenceUuid, $referenceIdNull])); $cart->addErrors( new ProductNotFoundError(Uuid::randomHex()), - new PromotionCartAddedInformationError(new LineItem(Uuid::randomHex(), 'promotion')), + new PromotionCartAddedInformationError($lineItem), new PurchaseStepsError(Uuid::randomHex(), 'Name', 2), ); @@ -606,6 +643,7 @@ public function testConvertToAppliedCouponsNoCoupons(): void $this->createMock(EntityRepository::class), $this->createMock(AbstractShippingMethodRoute::class), $this->createMock(ValidationIssues::class), + $this->createMock(EntityRepository::class), ); $currency = new CurrencyEntity(); @@ -656,14 +694,14 @@ private function createCart(): Cart new DeliveryPositionCollection(), new DeliveryDate(new \DateTime(), new \DateTime()), new ShippingMethodEntity(), - new ShippingLocation(new CountryEntity()), + new ShippingLocation(new CountryEntity(), null, null), new CalculatedPrice(10, 10, new CalculatedTaxCollection(), new TaxRuleCollection()) ); $delivery2 = new Delivery( new DeliveryPositionCollection(), new DeliveryDate(new \DateTime(), new \DateTime()), new ShippingMethodEntity(), - new ShippingLocation(new CountryEntity()), + new ShippingLocation(new CountryEntity(), null, null), new CalculatedPrice(5, 5, new CalculatedTaxCollection(), new TaxRuleCollection()) ); diff --git a/tests/AgentCommerce/Util/ShopwareCartTransformerTest.php b/tests/AgentCommerce/Util/ShopwareCartTransformerTest.php index aad156f0a..1466c4315 100644 --- a/tests/AgentCommerce/Util/ShopwareCartTransformerTest.php +++ b/tests/AgentCommerce/Util/ShopwareCartTransformerTest.php @@ -5,7 +5,7 @@ * file that was distributed with this source code. */ -namespace Swag\PayPal\Tests\AgentCommerce\Util; +namespace Swag\PayPal\Test\AgentCommerce\Util; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -19,9 +19,9 @@ use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Uuid\Uuid; use Shopware\Core\System\SalesChannel\SalesChannelContext; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\Coupon; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\PayPalCart; use Swag\PayPal\AgentCommerce\Exception\AgentException; +use Swag\PayPal\AgentCommerce\Struct\V1\Coupon; +use Swag\PayPal\AgentCommerce\Struct\V1\PayPalCart; use Swag\PayPal\AgentCommerce\Util\ShopwareCartTransformer; /** diff --git a/tests/AgentCommerce/Validation/CartTokenValidatorTest.php b/tests/AgentCommerce/Validation/CartTokenValidatorTest.php index cb035f270..44406f31c 100644 --- a/tests/AgentCommerce/Validation/CartTokenValidatorTest.php +++ b/tests/AgentCommerce/Validation/CartTokenValidatorTest.php @@ -5,7 +5,7 @@ * file that was distributed with this source code. */ -namespace Swag\PayPal\Tests\AgentCommerce\Validation; +namespace Swag\PayPal\Test\AgentCommerce\Validation; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; diff --git a/tests/AgentCommerce/Validation/HasScopesTest.php b/tests/AgentCommerce/Validation/HasScopesTest.php index ddc99b89b..8a63de463 100644 --- a/tests/AgentCommerce/Validation/HasScopesTest.php +++ b/tests/AgentCommerce/Validation/HasScopesTest.php @@ -5,7 +5,7 @@ * file that was distributed with this source code. */ -namespace Swag\PayPal\Tests\AgentCommerce\Validation; +namespace Swag\PayPal\Test\AgentCommerce\Validation; use Lcobucci\JWT\Token; use Lcobucci\JWT\Validation\ConstraintViolation; diff --git a/tests/AgentCommerce/Validation/ValidationIssuesTest.php b/tests/AgentCommerce/Validation/ValidationIssuesTest.php index 23cfb1f84..ba4efcabf 100644 --- a/tests/AgentCommerce/Validation/ValidationIssuesTest.php +++ b/tests/AgentCommerce/Validation/ValidationIssuesTest.php @@ -5,7 +5,7 @@ * file that was distributed with this source code. */ -namespace Swag\PayPal\Tests\AgentCommerce\Validation; +namespace Swag\PayPal\Test\AgentCommerce\Validation; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; @@ -25,13 +25,11 @@ use Shopware\Core\Checkout\Cart\Tax\Struct\CalculatedTaxCollection; use Shopware\Core\Checkout\Cart\Tax\Struct\TaxRuleCollection; use Shopware\Core\Checkout\Customer\Aggregate\CustomerAddress\CustomerAddressEntity; -use Shopware\Core\Checkout\Gateway\Error\CheckoutGatewayError; use Shopware\Core\Checkout\Payment\Cart\Error\PaymentMethodBlockedError; use Shopware\Core\Checkout\Promotion\Cart\Error\AutoPromotionNotFoundError; use Shopware\Core\Checkout\Promotion\Cart\Error\PromotionExcludedError; use Shopware\Core\Checkout\Promotion\Cart\Error\PromotionNotEligibleError; use Shopware\Core\Checkout\Promotion\Cart\Error\PromotionNotFoundError; -use Shopware\Core\Checkout\Promotion\Cart\Error\PromotionsOnCartPriceZeroError; use Shopware\Core\Checkout\Promotion\Cart\PromotionCartAddedInformationError; use Shopware\Core\Checkout\Promotion\Cart\PromotionCartDeletedInformationError; use Shopware\Core\Checkout\Shipping\Cart\Error\ShippingMethodBlockedError; @@ -46,13 +44,13 @@ use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Uuid\Uuid; use Shopware\Core\System\Currency\CurrencyEntity; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\Context\InventoryIssueContext; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\Context\PricingErrorContext; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\Referral\MetaData; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\ResolutionOption; -use Shopware\PayPalSDK\Struct\AgenticCommerce\V1\ValidationIssue; use Shopware\Storefront\Checkout\Cart\Error\PaymentMethodChangedError; use Shopware\Storefront\Checkout\Cart\Error\ShippingMethodChangedError; +use Swag\PayPal\AgentCommerce\Struct\V1\Context\InventoryIssueContext; +use Swag\PayPal\AgentCommerce\Struct\V1\Context\PricingErrorContext; +use Swag\PayPal\AgentCommerce\Struct\V1\Referral\MetaData; +use Swag\PayPal\AgentCommerce\Struct\V1\ResolutionOption; +use Swag\PayPal\AgentCommerce\Struct\V1\ValidationIssue; use Swag\PayPal\AgentCommerce\Validation\ValidationIssues; use Symfony\Component\Validator\ConstraintViolationList; @@ -246,23 +244,21 @@ public static function dataProviderCartError(): iterable yield ShippingAddressSalutationMissingError::class => [new ShippingAddressSalutationMissingError(self::createCustomerAddress()), $code]; yield GenericCartError::class => [new GenericCartError('foo', 'bar', [], Error::LEVEL_ERROR, false, false, false), $code]; yield IncompleteLineItemError::class => [new IncompleteLineItemError('foo', 'bar'), $code]; - yield CheckoutGatewayError::class => [new CheckoutGatewayError('foo', Error::LEVEL_NOTICE, true), $code]; - yield PaymentMethodBlockedError::class => [new PaymentMethodBlockedError('foo', 'reason', Uuid::randomHex()), $code]; // @phpstan-ignore method.deprecated + yield PaymentMethodBlockedError::class => [new PaymentMethodBlockedError('foo', 'reason'), $code]; yield AutoPromotionNotFoundError::class => [new AutoPromotionNotFoundError('foo'), $code]; yield PromotionExcludedError::class => [new PromotionExcludedError('foo'), $code]; yield PromotionNotEligibleError::class => [new PromotionNotEligibleError('foo'), $code]; yield PromotionNotFoundError::class => [new PromotionNotFoundError('foo'), $code]; - yield PromotionsOnCartPriceZeroError::class => [new PromotionsOnCartPriceZeroError(['foo', 'bar']), $code]; yield PromotionCartAddedInformationError::class => [new PromotionCartAddedInformationError(self::createLineItem()), $code]; yield PromotionCartDeletedInformationError::class => [new PromotionCartDeletedInformationError(self::createLineItem()), $code]; - yield ShippingMethodBlockedError::class => [new ShippingMethodBlockedError('foo', Uuid::randomHex(), 'reason'), $code]; // @phpstan-ignore method.deprecated + yield ShippingMethodBlockedError::class => [new ShippingMethodBlockedError('foo'), $code]; yield MinOrderQuantityError::class => [new MinOrderQuantityError(Uuid::randomHex(), 'foo', 5), ValidationIssue::CODE__INVENTORY_ISSUE]; yield ProductNotFoundError::class => [new ProductNotFoundError(Uuid::randomHex()), ValidationIssue::CODE__INVENTORY_ISSUE]; yield ProductOutOfStockError::class => [new ProductOutOfStockError(Uuid::randomHex(), 'foo'), ValidationIssue::CODE__INVENTORY_ISSUE]; yield ProductStockReachedError::class => [new ProductStockReachedError(Uuid::randomHex(), 'foo', 1), ValidationIssue::CODE__INVENTORY_ISSUE]; yield PurchaseStepsError::class => [new PurchaseStepsError(Uuid::randomHex(), 'foo', 5), ValidationIssue::CODE__INVENTORY_ISSUE]; - yield PaymentMethodChangedError::class => [new PaymentMethodChangedError('foo', 'bar', Uuid::randomHex(), Uuid::randomHex(), 'reason'), $code]; // @phpstan-ignore method.deprecated - yield ShippingMethodChangedError::class => [new ShippingMethodChangedError('foo', 'bar', Uuid::randomHex(), Uuid::randomHex(), 'reason'), $code]; // @phpstan-ignore method.deprecated + yield PaymentMethodChangedError::class => [new PaymentMethodChangedError('foo', 'bar'), $code]; + yield ShippingMethodChangedError::class => [new ShippingMethodChangedError('foo', 'bar'), $code]; } private static function createCustomerAddress(): CustomerAddressEntity diff --git a/tests/OpenAPISchemaTest.php b/tests/OpenAPISchemaTest.php index edb2445e1..8a751a6b6 100644 --- a/tests/OpenAPISchemaTest.php +++ b/tests/OpenAPISchemaTest.php @@ -16,6 +16,10 @@ use OpenApi\Generator; use PHPUnit\Framework\TestCase; use Shopware\Core\Framework\Log\Package; +use Swag\PayPal\AgentCommerce\SalesChannel\CheckoutRoute; +use Swag\PayPal\AgentCommerce\SalesChannel\CreateCartRoute; +use Swag\PayPal\AgentCommerce\SalesChannel\GetCartRoute; +use Swag\PayPal\AgentCommerce\SalesChannel\UpdateCartRoute; use Swag\PayPal\Checkout\ExpressCheckout\SalesChannel\ExpressCategoryRoute; use Swag\PayPal\Checkout\Plus\PlusPaymentFinalizeController; use Swag\PayPal\Checkout\Plus\PlusPaymentHandleController; @@ -53,6 +57,12 @@ class OpenAPISchemaTest extends TestCase '\\' . WebhookSystemConfigController::class . '::checkConfiguration', '\\' . WebhookSystemConfigController::class . '::getConfiguration', '\\' . WebhookSystemConfigController::class . '::getConfigurationValues', + + // Agent Commerce routes, no OpenAPI schema + '\\' . CheckoutRoute::class . '::checkout', + '\\' . GetCartRoute::class . '::getCart', + '\\' . UpdateCartRoute::class . '::updateCart', + '\\' . CreateCartRoute::class . '::createCart', ]; public const IGNORED_LOG_MESSAGES = [ diff --git a/tests/Util/IntrospectionProcessorTest.php b/tests/Util/IntrospectionProcessorTest.php index ea8860102..0643ec789 100644 --- a/tests/Util/IntrospectionProcessorTest.php +++ b/tests/Util/IntrospectionProcessorTest.php @@ -15,6 +15,9 @@ use PHPUnit\Framework\TestCase; use Shopware\Core\Framework\Log\Package; use Shopware\Core\Kernel; +use Swag\PayPal\AgentCommerce\Exception\AgentException; +use Swag\PayPal\AgentCommerce\Struct\V1\AgentErrorDetail; +use Swag\PayPal\AgentCommerce\Struct\V1\AgentErrorDetailCollection; use Swag\PayPal\Checkout\Payment\Handler\PayPalHandler; use Swag\PayPal\Pos\Api\Exception\PosException; use Swag\PayPal\RestApi\Client\AbstractClient; @@ -274,5 +277,43 @@ public static function invokeWithExceptionDataProvider(): \Generator 'errorCode' => 'SWAG_PAYPAL__POS_EXCEPTION', ]], ]; + + yield 'AgentException' => [ + ['exception' => new AgentException( + 500, + 'TEST_ERROR', + 'Test error message', + [], + new AgentErrorDetailCollection([ + (new AgentErrorDetail())->assign([ + 'field' => 'field1', + 'issue' => 'issue1', + 'description' => 'description1', + ]), + (new AgentErrorDetail())->assign([ + 'field' => 'field2', + 'issue' => 'issue2', + 'description' => 'description2', + ]), + ]) + )], + ['exception' => [ + 'message' => 'Test error message', + 'parameters' => [], + 'errorCode' => 'TEST_ERROR', + 'details' => [ + [ + 'field' => 'field1', + 'issue' => 'issue1', + 'description' => 'description1', + ], + [ + 'field' => 'field2', + 'issue' => 'issue2', + 'description' => 'description2', + ], + ], + ]], + ]; } } From 926125b416fa2b2ef2a3c4866473eca2399adbd3 Mon Sep 17 00:00:00 2001 From: Fabian Boensch Date: Mon, 3 Nov 2025 15:33:49 +0100 Subject: [PATCH 04/23] fix test for <6.6.2 --- .../SalesChannel/CreateCartRoute.php | 5 +++++ src/AgentCommerce/Util/PayPalCartTransformer.php | 13 ++++++++++--- .../sw-sales-channel-detail-base.html.twig | 10 +++++----- .../sw-sales-channel-detail-base.scss | 5 ----- .../SalesChannel/CreateCartRouteTest.php | 2 ++ .../Util/PayPalCartTransformerTest.php | 15 ++++++++++++--- .../Util/ShopwareCartTransformerTest.php | 2 -- 7 files changed, 34 insertions(+), 18 deletions(-) diff --git a/src/AgentCommerce/SalesChannel/CreateCartRoute.php b/src/AgentCommerce/SalesChannel/CreateCartRoute.php index 8eb11e5c6..3569dde04 100644 --- a/src/AgentCommerce/SalesChannel/CreateCartRoute.php +++ b/src/AgentCommerce/SalesChannel/CreateCartRoute.php @@ -14,6 +14,7 @@ use Shopware\Core\Framework\Validation\DataBag\RequestDataBag; use Shopware\Core\System\SalesChannel\Context\SalesChannelContextService; use Shopware\Core\System\SalesChannel\SalesChannelContext; +use Swag\PayPal\AgentCommerce\Exception\AgentException; use Swag\PayPal\AgentCommerce\Routing\AgentSource; use Swag\PayPal\AgentCommerce\SalesChannel\Response\AgentCartResponse; use Swag\PayPal\AgentCommerce\Struct\V1\PayPalCart; @@ -57,6 +58,10 @@ public function createCart(Request $request, SalesChannelContext $salesChannelCo $swCart = $this->cartService->createNew($salesChannelContext->getToken()); $swCart = $this->cartService->add($swCart, $this->shopwareCartTransformer->getLineItems($payPalCart, $salesChannelContext), $salesChannelContext); + if (!$swCart->getLineItems()->count()) { + throw AgentException::requiredFieldInvalid('cart.items', 'no valid item found'); + } + $orderId = $this->upsertPayPalOrder($payPalCart, $swCart, $request->request->all(), $salesChannelContext); $createdPayPalCart = $this->payPalCartTransformer->convertToPayPalCart($swCart, $salesChannelContext, $payPalCart); diff --git a/src/AgentCommerce/Util/PayPalCartTransformer.php b/src/AgentCommerce/Util/PayPalCartTransformer.php index e8f9b1faa..f0409f97b 100644 --- a/src/AgentCommerce/Util/PayPalCartTransformer.php +++ b/src/AgentCommerce/Util/PayPalCartTransformer.php @@ -11,6 +11,7 @@ use Shopware\Core\Checkout\Cart\Delivery\Struct\DeliveryDate; use Shopware\Core\Checkout\Cart\Delivery\Struct\DeliveryTime; use Shopware\Core\Checkout\Cart\LineItem\LineItem; +use Shopware\Core\Checkout\Cart\LineItem\LineItemCollection; use Shopware\Core\Checkout\Customer\Aggregate\CustomerAddress\CustomerAddressEntity; use Shopware\Core\Checkout\Customer\CustomerEntity; use Shopware\Core\Checkout\Shipping\SalesChannel\AbstractShippingMethodRoute; @@ -57,7 +58,7 @@ class PayPalCartTransformer /** * @param EntityRepository $productRepository * @param EntityRepository $countryRepository - * @param EntityRepository $countryRepository + * @param EntityRepository $localeRepository */ public function __construct( private readonly EntityRepository $productRepository, @@ -309,8 +310,11 @@ public function createTotals(Cart $cart, SalesChannelContext $context): CartTota $iso = $context->getCurrency()->getIsoCode(); $cartPrice = $cart->getPrice(); + $promotions = $cart->getLineItems()->filterFlatByType(LineItem::PROMOTION_LINE_ITEM_TYPE); + $promotionDiscount = (new LineItemCollection($promotions))->getPrices()->sum(); + $subtotal = new Money(); - $subtotal->setValue((string) $cartPrice->getPositionPrice()); + $subtotal->setValue((string) ($cartPrice->getPositionPrice() - $promotionDiscount->getTotalPrice())); $subtotal->setCurrencyCode($iso); $shipping = new Money(); @@ -325,13 +329,16 @@ public function createTotals(Cart $cart, SalesChannelContext $context): CartTota $total->setValue((string) $cartPrice->getTotalPrice()); $total->setCurrencyCode($iso); - // TODO: discount need to be done, when coupons are implemented + $discount = new Money(); + $discount->setValue((string) ($promotionDiscount->getTotalPrice() * -1)); + $discount->setCurrencyCode($iso); $totals = new CartTotals(); $totals->setSubtotal($subtotal); $totals->setShipping($shipping); $totals->setTax($tax); $totals->setTotal($total); + $totals->setDiscount($discount); return $totals; } diff --git a/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/extension/sw-sales-channel-detail-base/sw-sales-channel-detail-base.html.twig b/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/extension/sw-sales-channel-detail-base/sw-sales-channel-detail-base.html.twig index 7a34d49e1..8414b9432 100644 --- a/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/extension/sw-sales-channel-detail-base/sw-sales-channel-detail-base.html.twig +++ b/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/extension/sw-sales-channel-detail-base/sw-sales-channel-detail-base.html.twig @@ -61,13 +61,13 @@ {% endblock %} {% block sw_sales_channel_detail_base_options_delete %} -