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/composer.json b/composer.json index 6c495a210..947865159 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "swag/paypal", "description": "PayPal integration for Shopware 6", - "version": "9.10.2", + "version": "9.10.62", "type": "shopware-platform-plugin", "license": "MIT", "authors": [ diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 040cf456e..7d32d6372 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -396,3 +396,71 @@ parameters: message: '#^Call to static method getContainer\(\) of internal class ShopwarePluginClassTest\.$#' count: 2 path: tests/ShopwarePluginClassTest.php + + - + message: '#^Parameter \$contextService of method Swag\\PayPal\\AgentCommerce\\Routing\\AgentRequestContextResolver\:\:__construct\(\) has typehint with internal interface Shopware\\Core\\System\\SalesChannel\\Context\\SalesChannelContextServiceInterface\.$#' + count: 1 + path: src/AgentCommerce/Routing/AgentRequestContextResolver.php + + - + message: '#^Property \$contextService references internal interface Shopware\\Core\\System\\SalesChannel\\Context\\SalesChannelContextServiceInterface in its type\.$#' + count: 1 + path: src/AgentCommerce/Routing/AgentRequestContextResolver.php + + - + message: '#^Call to method get\(\) of internal interface Shopware\\Core\\System\\SalesChannel\\Context\\SalesChannelContextServiceInterface from outside its root namespace Shopware\.$#' + count: 1 + path: src/AgentCommerce/Routing/AgentRequestContextResolver.php + + - + message: '#^Parameter \$contextService of method Swag\\PayPal\\AgentCommerce\\Util\\FaviconLoader\:\:__construct\(\) has typehint with internal interface Shopware\\Core\\System\\SalesChannel\\Context\\SalesChannelContextServiceInterface\.$#' + count: 1 + path: src/AgentCommerce/Util/FaviconLoader.php + + - + message: '#^Property \$contextService references internal interface Shopware\\Core\\System\\SalesChannel\\Context\\SalesChannelContextServiceInterface in its type\.$#' + count: 1 + path: src/AgentCommerce/Util/FaviconLoader.php + + - + message: '#^Call to method get\(\) of internal interface Shopware\\Core\\System\\SalesChannel\\Context\\SalesChannelContextServiceInterface from outside its root namespace Shopware\.$#' + count: 1 + path: src/AgentCommerce/Util/FaviconLoader.php + + - + message: '#^Property \$contextService references internal interface Shopware\\Core\\System\\SalesChannel\\Context\\SalesChannelContextServiceInterface in its type\.$#' + count: 1 + path: src/AgentCommerce/SalesChannel/AbstractAgentCommerceRoute.php + + - + message: '#^Call to method get\(\) of internal interface Shopware\\Core\\System\\SalesChannel\\Context\\SalesChannelContextServiceInterface from outside its root namespace Shopware\.$#' + count: 1 + path: src/AgentCommerce/SalesChannel/AbstractAgentCommerceRoute.php + + - + message: '#^Parameter \$contextService of method Swag\\PayPal\\AgentCommerce\\SalesChannel\\UpdateCartRoute\:\:__construct\(\) has typehint with internal interface Shopware\\Core\\System\\SalesChannel\\Context\\SalesChannelContextServiceInterface\.$#' + count: 1 + path: src/AgentCommerce/SalesChannel/UpdateCartRoute.php + + - + message: '#^Property \$contextService references internal interface Shopware\\Core\\System\\SalesChannel\\Context\\SalesChannelContextServiceInterface in its type\.$#' + count: 1 + path: src/AgentCommerce/SalesChannel/UpdateCartRoute.php + + - + message: '#^Parameter \$contextService of method Swag\\PayPal\\AgentCommerce\\SalesChannel\\CreateCartRoute\:\:__construct\(\) has typehint with internal interface Shopware\\Core\\System\\SalesChannel\\Context\\SalesChannelContextServiceInterface\.$#' + count: 1 + path: src/AgentCommerce/SalesChannel/CreateCartRoute.php + + - + message: '#^Property \$contextService references internal interface Shopware\\Core\\System\\SalesChannel\\Context\\SalesChannelContextServiceInterface in its type\.$#' + count: 1 + path: src/AgentCommerce/SalesChannel/CreateCartRoute.php + + - + message: ''' + #^Call to deprecated method getDefaults\(\) of class Symfony\\Component\\Routing\\Attribute\\Route\: + Use the \"defaults\" property instead$# + ''' + count: 2 + path: tests/AgentCommerce/SalesChannel/DefaultRouteScopeTest.php 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/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/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/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/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..d26fc8547 --- /dev/null +++ b/src/AgentCommerce/HoneyWebhookService.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; + +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(); + } + + if (!$productExport->__isset('storefrontSalesChannel')) { + throw HoneyWebhookException::storefrontSalesChannelNotFound(); + } + + $storefront = $productExport->getStorefrontSalesChannel(); + $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', + ]); + + /** @var SalesChannelEntity|null $salesChannel */ + $salesChannel = $this->salesChannelRepository->search($criteria, $context)->first(); + + return $salesChannel; + } + + 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/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/Routing/AgentRequestContextResolver.php b/src/AgentCommerce/Routing/AgentRequestContextResolver.php new file mode 100644 index 000000000..2b648c194 --- /dev/null +++ b/src/AgentCommerce/Routing/AgentRequestContextResolver.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\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\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\SalesChannelContextServiceInterface; +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\Constraint\PayPalExternalId; +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\Type; +use Symfony\Component\Validator\Exception\InvalidArgumentException; + +/** + * @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 RouteScopeRegistry $routeScopeRegistry, + private readonly SalesChannelContextServiceInterface $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'); + } + + $token = $this->extractJwtFromAuthorizationHeader($token); + + $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), + ); + + /** @var ProductExportEntity|null $productExport */ + $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->validate($jwt, ...$constraints); + } + + private function extractJwtFromAuthorizationHeader(string $authorization): string + { + $authorization = trim($authorization); + + // Accept both: "Bearer " and "" (backward compatible) + if (\preg_match('/^Bearer\s+(.+)$/i', $authorization, $m) === 1) { + return trim($m[1]); + } + + return $authorization; + } + + private function resolveContextSource(string $token): AgentSource + { + try { + /** @var array{external_id: list, sub: string, iat: \DateTimeInterface, exp: \DateTimeInterface, scope: list, debug_id?: string} $decoded */ + $decoded = $this->decode($token); + } 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 PayPalExternalId()) + ->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(constraints: [new Type('string'), new NotBlank()])) + ->add('debug_id', new Optional([new Type('string')])); + + try { + $this->validator->validate($decoded, $definition); + } catch (ConstraintViolationException|InvalidArgumentException $e) { + throw AgentException::unauthorized('Invalid JWT token', $e); + } + + return new AgentSource(self::extractPayPalMerchantId($decoded['external_id']), $decoded['iat'], $decoded['exp'], $decoded['scope'], $decoded['sub'], $decoded['debug_id'] ?? null); + } + + /** + * @param list $externalIds + */ + private static function extractPayPalMerchantId(array $externalIds): string + { + foreach ($externalIds as $entry) { + if (!\is_string($entry) || !\str_starts_with($entry, 'PayPal:')) { + continue; + } + + if (\preg_match('/^PayPal:\s*(.+)$/', $entry, $m) === 1) { + return $m[1]; + } + } + + throw AgentException::unauthorized('external_id must contain at least one PayPal:* entry.'); + } + + 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 new file mode 100644 index 000000000..ccd2968ee --- /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:v10.0.0 - Will be natively typed + */ + protected $allowedPaths = ['api']; // @phpstan-ignore shopware.propertyNativeType + + 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..cc93c2b3d --- /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\SalesChannelContextServiceInterface; +use Shopware\Core\System\SalesChannel\Context\SalesChannelContextServiceParameters; +use Shopware\Core\System\SalesChannel\SalesChannelContext; +use Swag\PayPal\AgentCommerce\Struct\V1\PaymentMethod; + +/** + * @internal + */ +#[Package('checkout')] +abstract class AbstractAgentCommerceRoute +{ + protected SalesChannelContextServiceInterface $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..20b385be3 --- /dev/null +++ b/src/AgentCommerce/SalesChannel/CheckoutRoute.php @@ -0,0 +1,112 @@ + + * 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\AbstractCartOrderRoute; +use Shopware\Core\Checkout\Cart\SalesChannel\CartService; +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 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\Api\Common\Link; +use Swag\PayPal\RestApi\V2\Api\Order; +use Swag\PayPal\RestApi\V2\PaymentStatusV2; +use Swag\PayPal\RestApi\V2\Resource\OrderResource; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Routing\Attribute\Route; + +/** + * @internal + */ +#[Package('checkout')] +#[Route(defaults: ['_routeScope' => ['paypal-agent'], '_agentScope' => [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 + { + $cart = $this->cartService->getCart(CartTokenValidator::validateCartToken($token), $context); + if (!$cart->getLineItems()->count()) { + // We don't create a cart with empty items. So it must be created. + throw AgentException::cartNotFound($token); + } + + $body = \json_decode($request->getContent(), true, flags: \JSON_THROW_ON_ERROR); + $payPalOrder = $this->orderResource->get($body['payment_method']['token'], $context->getSalesChannelId()); + if ($payPalOrder->getStatus() !== PaymentStatusV2::ORDER_APPROVED) { + return new AgentCartResponse($this->handleNotApprovedOrder($payPalOrder)); + } + + $payPalCart = $this->cartTransformer->convertToPayPalCart($cart, $context); + $payPalCart->setPaymentMethod($this->createPaymentMethod($payPalOrder->getId())); + + try { + $this->handleOrder($request, $payPalOrder, $cart, $context); + $payPalCart->setStatus(PayPalCart::STATUS__COMPLETE); + } catch (PaymentException) { + $payPalCart->setStatus(PayPalCart::STATUS__INCOMPLETE); + $payPalCart->setValidationStatus(PayPalCart::VALIDATION_STATUS__INVALID); + } + + return new AgentCartResponse($payPalCart); + } + + private function handleNotApprovedOrder(Order $payPalOrder): PayPalCart + { + $payPalCart = new PayPalCart(); + $payPalCart->setStatus(PayPalCart::STATUS__INCOMPLETE); + $payPalCart->setValidationStatus(PayPalCart::VALIDATION_STATUS__INVALID); + $payPalCart->setPaymentMethod($this->createPaymentMethod($payPalOrder->getId())); + + $link = $payPalOrder->getLinks()->getRelation(Link::RELATION_APPROVE) + ?? $payPalOrder->getLinks()->getRelation(Link::RELATION_PAYER_ACTION); + $payPalCart->getPaymentMethod()?->setApprovalUrl($link?->getHref()); + + return $payPalCart; + } + + private function handleOrder(Request $request, Order $payPalOrder, Cart $cart, SalesChannelContext $context): void + { + $order = $this->orderRoute + ->order($cart, $context, new RequestDataBag($request->request->all())) + ->getOrder(); + + $primaryTransaction = $order->getTransactions()?->last(); + if (!$primaryTransaction) { + throw AgentException::orderSystemError(); + } + + $request->request->set(AbstractPaymentMethodHandler::PAYPAL_PAYMENT_ORDER_ID_INPUT_NAME, $payPalOrder->getId()); + $request->query->set(PayPalPaymentHandler::PAYPAL_REQUEST_PARAMETER_TOKEN, $payPalOrder->getId()); + + // @phpstan-ignore new.deprecated + $payment = new AsyncPaymentTransactionStruct($primaryTransaction, $order, ''); + + $this->paymentHandler->pay($payment, new RequestDataBag($request->request->all()), $context); + $this->paymentHandler->finalize($payment, $request, $context); + } +} diff --git a/src/AgentCommerce/SalesChannel/CreateCartRoute.php b/src/AgentCommerce/SalesChannel/CreateCartRoute.php new file mode 100644 index 000000000..f8aedd815 --- /dev/null +++ b/src/AgentCommerce/SalesChannel/CreateCartRoute.php @@ -0,0 +1,110 @@ + + * 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\SalesChannelContextServiceInterface; +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; +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; +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 SalesChannelContextServiceInterface $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); + + 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); + + $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); + + $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..077c3c6ab --- /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 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; +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..ad3f5354c --- /dev/null +++ b/src/AgentCommerce/SalesChannel/Response/AgentCartResponse.php @@ -0,0 +1,30 @@ + + * 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 Swag\PayPal\AgentCommerce\Struct\V1\PayPalCart; + +#[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/Response/AgentResponseExceptionSubscriber.php b/src/AgentCommerce/SalesChannel/Response/AgentResponseExceptionSubscriber.php new file mode 100644 index 000000000..35db26b75 --- /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($this->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/SalesChannel/UpdateCartRoute.php b/src/AgentCommerce/SalesChannel/UpdateCartRoute.php new file mode 100644 index 000000000..1c5ef237a --- /dev/null +++ b/src/AgentCommerce/SalesChannel/UpdateCartRoute.php @@ -0,0 +1,161 @@ + + * 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\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\Context\SalesChannelContextServiceInterface; +use Shopware\Core\System\SalesChannel\SalesChannel\AbstractContextSwitchRoute; +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; +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_CART]])] +class UpdateCartRoute extends AbstractAgentCommerceRoute +{ + /** + * @param EntityRepository $customerRepository + * @param EntityRepository $customerAddressRepository + */ + public function __construct( + protected SalesChannelContextServiceInterface $contextService, + private readonly ShopwareCartTransformer $shopwareCartTransformer, + private readonly CreateCartRoute $createCartRoute, + private readonly EntityRepository $customerRepository, + private readonly EntityRepository $customerAddressRepository, + private readonly CartService $cartService, + private readonly AbstractContextSwitchRoute $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()) { + $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); + } + + 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); + $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/Struct/V1/Address.php b/src/AgentCommerce/Struct/V1/Address.php new file mode 100644 index 000000000..fd6ed63a1 --- /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 Swag\PayPal\RestApi\PayPalApiStruct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema( + schema: 'paypal_agentic_commerce_v1_address', + required: ['countryCode'] +)] +class Address extends PayPalApiStruct +{ + /** + * 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 $addressLine_1 = null; + + #[OA\Property( + type: 'string', + maxLength: 300, + minLength: 0, + )] + protected ?string $addressLine_2 = 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 $adminArea_1 = null; + + /** + * A city, town, or village. Smaller than admin_area_level_1. + */ + #[OA\Property( + type: 'string', + maxLength: 120, + minLength: 0, + )] + protected ?string $adminArea_2 = 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->addressLine_1; + } + + public function setAddressLine1(?string $addressLine1): void + { + $this->addressLine_1 = $addressLine1; + } + + public function getAddressLine2(): ?string + { + return $this->addressLine_2; + } + + public function setAddressLine2(?string $addressLine2): void + { + $this->addressLine_2 = $addressLine2; + } + + public function getAdminArea1(): ?string + { + return $this->adminArea_1; + } + + public function setAdminArea1(?string $adminArea1): void + { + $this->adminArea_1 = $adminArea1; + } + + public function getAdminArea2(): ?string + { + return $this->adminArea_2; + } + + public function setAdminArea2(?string $adminArea2): void + { + $this->adminArea_2 = $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..df5d75f2f --- /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 Swag\PayPal\RestApi\PayPalApiStruct; + +/** + * @experimental + */ +#[Package('checkout')] +class AgentError extends PayPalApiStruct +{ + 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..0df73094a --- /dev/null +++ b/src/AgentCommerce/Struct/V1/AgentErrorDetail.php @@ -0,0 +1,56 @@ + + * 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 Swag\PayPal\RestApi\PayPalApiStruct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema(schema: 'paypal_agentic_commerce_v1_agent_error_detail')] +class AgentErrorDetail extends PayPalApiStruct +{ + 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..81656b52d --- /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 Swag\PayPal\RestApi\PayPalApiCollection; + +/** + * @experimental + * + * @extends PayPalApiCollection + */ +#[Package('checkout')] +class AgentErrorDetailCollection extends PayPalApiCollection +{ + public static 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..33a7bbe15 --- /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 Swag\PayPal\RestApi\PayPalApiStruct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema(schema: 'paypal_agentic_commerce_v1_applied_coupon')] +class AppliedCoupon extends PayPalApiStruct +{ + #[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..2df1bf3d1 --- /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 Swag\PayPal\RestApi\PayPalApiCollection; + +/** + * @experimental + * + * @extends PayPalApiCollection + */ +#[Package('checkout')] +class AppliedCouponCollection extends PayPalApiCollection +{ + public static 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..a138465c0 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/BillingAddress.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; + +use OpenApi\Attributes as OA; +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')] +#[OA\Schema( + schema: 'paypal_agentic_commerce_v1_billing_address', + required: ['countryCode'] +)] +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..2654ea1f2 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/CartItem.php @@ -0,0 +1,196 @@ + + * 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 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 + */ +#[Package('checkout')] +#[OA\Schema( + schema: 'paypal_agentic_commerce_v1_cart_item', + required: ['itemId', 'quantity', 'price'] +)] +class CartItem extends PayPalApiStruct +{ + /** + * 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(type: 'array', items: new OA\Items(ref: SelectedAttribute::class))] + protected SelectedAttributeCollection $selectedAttributes; + + #[OA\Property(ref: GiftOptions::class)] + protected ?GiftOptions $giftOptions = null; + + #[OA\Property(type: 'array', items: new OA\Items(ref: CustomOption::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..0a6e20331 --- /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 Swag\PayPal\RestApi\PayPalApiCollection; + +/** + * @experimental + * + * @extends PayPalApiCollection + */ +#[Package('checkout')] +class CartItemCollection extends PayPalApiCollection +{ + public static 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..4c54118c2 --- /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 Swag\PayPal\RestApi\PayPalApiStruct; + +/** + * @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 PayPalApiStruct +{ + #[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..3546c4524 --- /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 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; +use Swag\PayPal\RestApi\PayPalApiStruct; + +/** + * @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 PayPalApiStruct +{ + 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 PayPalApiStruct $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(): PayPalApiStruct + { + return $this->value; + } + + public function setValue(PayPalApiStruct $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..3332cc6ab --- /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 Swag\PayPal\RestApi\PayPalApiCollection; + +/** + * @experimental + * + * @extends PayPalApiCollection + */ +#[Package('checkout')] +class CheckoutFieldCollection extends PayPalApiCollection +{ + 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 new file mode 100644 index 000000000..9fad69b7c --- /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 Swag\PayPal\RestApi\PayPalApiStruct; + +/** + * @experimental + */ +#[Package('checkout')] +abstract class AbstractContext extends PayPalApiStruct +{ + /** + * 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..b3f214cdf --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Context/BusinessRuleErrorContext.php @@ -0,0 +1,404 @@ + + * 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\BusinessHour; +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(type: 'array', items: new OA\Items(ref: BusinessHour::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..5e51ce39e --- /dev/null +++ b/src/AgentCommerce/Struct/V1/Context/PricingErrorContext.php @@ -0,0 +1,407 @@ + + * 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\MixedItem; +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(type: 'array', items: new OA\Items(ref: MixedItem::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..65d530d0b --- /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(type: 'array', items: new OA\Items(ref: SuggestedCorrection::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..6a8c76c6b --- /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 Swag\PayPal\RestApi\PayPalApiStruct; + +/** + * @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 PayPalApiStruct +{ + 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..9181f32a5 --- /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 Swag\PayPal\RestApi\PayPalApiCollection; + +/** + * @experimental + * + * @extends PayPalApiCollection + */ +#[Package('checkout')] +class CouponCollection extends PayPalApiCollection +{ + public static 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..769698bf5 --- /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 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 PayPalApiStruct +{ + #[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..15b542899 --- /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 Swag\PayPal\RestApi\PayPalApiStruct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema( + schema: 'paypal_agentic_commerce_v1_error', + required: ['name', 'message'] +)] +class Error extends PayPalApiStruct +{ + /** + * 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(type: 'array', items: new OA\Items(ref: AgentErrorDetail::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..5b5cf5f6e --- /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 Swag\PayPal\RestApi\PayPalApiStruct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema(schema: 'paypal_agentic_commerce_v1_geo_coordinates')] +class GeoCoordinates extends PayPalApiStruct +{ + /** + * 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..ea6d75555 --- /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 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 PayPalApiStruct +{ + /** + * 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..a124d2b0f --- /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 Swag\PayPal\RestApi\PayPalApiStruct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema( + schema: 'paypal_agentic_commerce_v1_link', + required: ['rel', 'href'] +)] +class Link extends PayPalApiStruct +{ + public const REL__SELF = 'self'; + 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..11d63e22d --- /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 Swag\PayPal\RestApi\PayPalApiCollection; + +/** + * @experimental + * + * @extends PayPalApiCollection + */ +#[Package('checkout')] +class LinkCollection extends PayPalApiCollection +{ + public static 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..ca38860a7 --- /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 Swag\PayPal\RestApi\PayPalApiStruct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema( + schema: 'paypal_agentic_commerce_v1_money', + required: ['currencyCode', 'value'] +)] +class Money extends PayPalApiStruct +{ + /** + * 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..7cb97aff8 --- /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 Swag\PayPal\RestApi\PayPalApiStruct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema( + schema: 'paypal_agentic_commerce_v1_pay_pal_cart', + required: ['items', 'paymentMethod'] +)] +class PayPalCart extends PayPalApiStruct +{ + 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(type: 'array', items: new OA\Items(ref: ValidationIssue::class))] + protected ValidationIssueCollection $validationIssues; + + #[OA\Property(ref: CartTotals::class)] + protected ?CartTotals $totals = null; + + /** + * Successfully applied coupons (server-calculated) + */ + #[OA\Property(type: 'array', items: new OA\Items(ref: AppliedCoupon::class))] + protected ?AppliedCouponCollection $appliedCoupons = null; + + /** + * Available shipping methods with selection state + */ + #[OA\Property(type: 'array', items: new OA\Items(ref: ShippingOption::class))] + protected ?ShippingOptionCollection $availableShippingOptions = null; + + /** + * HATEOAS navigation links for cart operations + */ + #[OA\Property(type: 'array', items: new OA\Items(ref: Link::class))] + protected ?LinkCollection $links = null; + + /** + * Products in the cart + */ + #[OA\Property(type: 'array', items: new OA\Items(ref: CartItem::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(type: 'array', items: new OA\Items(ref: CheckoutField::class))] + protected ?CheckoutFieldCollection $checkoutFields = null; + + /** + * Discount coupons to apply or remove from cart + */ + #[OA\Property(type: 'array', items: new OA\Items(ref: Coupon::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..cca23ffd5 --- /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 Swag\PayPal\RestApi\PayPalApiStruct; + +/** + * @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 PayPalApiStruct +{ + /** + * 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..fa3efc4e8 --- /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 Swag\PayPal\RestApi\PayPalApiStruct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema( + schema: 'paypal_agentic_commerce_v1_phone', + required: ['countryCode', 'nationalNumber'] +)] +class Phone extends PayPalApiStruct +{ + 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..024152782 --- /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 Swag\PayPal\RestApi\PayPalApiStruct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema(schema: 'paypal_agentic_commerce_v1_referral_business_hour')] +class BusinessHour extends PayPalApiStruct +{ + #[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..c3c36ce87 --- /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 Swag\PayPal\RestApi\PayPalApiCollection; + +/** + * @experimental + * + * @extends PayPalApiCollection + */ +#[Package('checkout')] +class BusinessHourCollection extends PayPalApiCollection +{ + 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 new file mode 100644 index 000000000..ad5cbc983 --- /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 Swag\PayPal\RestApi\PayPalApiStruct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema(schema: 'paypal_agentic_commerce_v1_referral_custom_option')] +class CustomOption extends PayPalApiStruct +{ + #[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..12a7ea124 --- /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 Swag\PayPal\RestApi\PayPalApiCollection; + +/** + * @experimental + * + * @extends PayPalApiCollection + */ +#[Package('checkout')] +class CustomOptionCollection extends PayPalApiCollection +{ + 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 new file mode 100644 index 000000000..b37a67036 --- /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 Swag\PayPal\RestApi\PayPalApiStruct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema(schema: 'paypal_agentic_commerce_v1_referral_customer_name')] +class CustomerName extends PayPalApiStruct +{ + #[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..5afe66831 --- /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 Swag\PayPal\RestApi\PayPalApiStruct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema(schema: 'paypal_agentic_commerce_v1_referral_measurements')] +class Measurements extends PayPalApiStruct +{ + #[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..1834cf54b --- /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 Swag\PayPal\RestApi\PayPalApiStruct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema(schema: 'paypal_agentic_commerce_v1_referral_meta_data')] +class MetaData extends PayPalApiStruct +{ + 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..ebaf49d9a --- /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 Swag\PayPal\RestApi\PayPalApiCollection; + +/** + * @experimental + * + * @extends PayPalApiCollection + */ +#[Package('checkout')] +class MetaDataCollection extends PayPalApiCollection +{ + 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 new file mode 100644 index 000000000..a105c2a85 --- /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 Swag\PayPal\RestApi\PayPalApiStruct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema(schema: 'paypal_agentic_commerce_v1_referral_mixed_item')] +class MixedItem extends PayPalApiStruct +{ + #[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..267411a89 --- /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 Swag\PayPal\RestApi\PayPalApiCollection; + +/** + * @experimental + * + * @extends PayPalApiCollection + */ +#[Package('checkout')] +class MixedItemCollection extends PayPalApiCollection +{ + 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 new file mode 100644 index 000000000..29b413f07 --- /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 Swag\PayPal\RestApi\PayPalApiStruct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema(schema: 'paypal_agentic_commerce_v1_referral_recipient')] +class Recipient extends PayPalApiStruct +{ + #[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..e982b66a4 --- /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 Swag\PayPal\RestApi\PayPalApiStruct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema(schema: 'paypal_agentic_commerce_v1_referral_selected_attribute')] +class SelectedAttribute extends PayPalApiStruct +{ + #[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..7926bb943 --- /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 Swag\PayPal\RestApi\PayPalApiCollection; + +/** + * @experimental + * + * @extends PayPalApiCollection + */ +#[Package('checkout')] +class SelectedAttributeCollection extends PayPalApiCollection +{ + 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 new file mode 100644 index 000000000..202d45133 --- /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 Swag\PayPal\RestApi\PayPalApiStruct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema(schema: 'paypal_agentic_commerce_v1_referral_suggested_correction')] +class SuggestedCorrection extends PayPalApiStruct +{ + #[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..caf1b6206 --- /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 Swag\PayPal\RestApi\PayPalApiCollection; + +/** + * @experimental + * + * @extends PayPalApiCollection + */ +#[Package('checkout')] +class SuggestedCorrectionCollection extends PayPalApiCollection +{ + public static 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..579618d07 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/ResolutionOption.php @@ -0,0 +1,148 @@ + + * 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 Swag\PayPal\AgentCommerce\Struct\V1\Referral\MetaData; +use Swag\PayPal\RestApi\PayPalApiStruct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema( + schema: 'paypal_agentic_commerce_v1_resolution_option', + required: ['action', 'label'] +)] +class ResolutionOption extends PayPalApiStruct +{ + 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()); + } +} diff --git a/src/AgentCommerce/Struct/V1/ResolutionOptionCollection.php b/src/AgentCommerce/Struct/V1/ResolutionOptionCollection.php new file mode 100644 index 000000000..f0a9aefcb --- /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 Swag\PayPal\RestApi\PayPalApiCollection; + +/** + * @experimental + * + * @extends PayPalApiCollection + */ +#[Package('checkout')] +class ResolutionOptionCollection extends PayPalApiCollection +{ + public static 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..82749a0b5 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/ShippingAddress.php @@ -0,0 +1,23 @@ + + * 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; + +/** + * @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 new file mode 100644 index 000000000..af7bd6dcf --- /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 Swag\PayPal\RestApi\PayPalApiStruct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema( + schema: 'paypal_agentic_commerce_v1_shipping_option', + required: ['price', 'isSelected'] +)] +class ShippingOption extends PayPalApiStruct +{ + /** + * 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..fbd9f9e93 --- /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 Swag\PayPal\RestApi\PayPalApiCollection; + +/** + * @experimental + * + * @extends PayPalApiCollection + */ +#[Package('checkout')] +class ShippingOptionCollection extends PayPalApiCollection +{ + public static 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..f6f639319 --- /dev/null +++ b/src/AgentCommerce/Struct/V1/ValidationIssue.php @@ -0,0 +1,206 @@ + + * 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 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; +use Swag\PayPal\RestApi\PayPalApiStruct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema( + schema: 'paypal_agentic_commerce_v1_validation_issue', + required: ['code', 'type', 'message'] +)] +class ValidationIssue extends PayPalApiStruct +{ + 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(type: 'array', items: new OA\Items(ref: ResolutionOption::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()); + } +} diff --git a/src/AgentCommerce/Struct/V1/ValidationIssueCollection.php b/src/AgentCommerce/Struct/V1/ValidationIssueCollection.php new file mode 100644 index 000000000..1aeefd5d7 --- /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 Swag\PayPal\RestApi\PayPalApiCollection; + +/** + * @experimental + * + * @extends PayPalApiCollection + */ +#[Package('checkout')] +class ValidationIssueCollection extends PayPalApiCollection +{ + public static 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..11d9754d8 --- /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 Swag\PayPal\RestApi\PayPalApiStruct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema(schema: 'paypal_agentic_commerce_v1_value_allergy_information_value')] +class AllergyInformationValue extends PayPalApiStruct +{ + 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..1f5685199 --- /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 Swag\PayPal\RestApi\PayPalApiStruct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema( + schema: 'paypal_agentic_commerce_v1_value_custom_engraving_text_value', + required: ['text'] +)] +class CustomEngravingTextValue extends PayPalApiStruct +{ + 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..3fffa9519 --- /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 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 PayPalApiStruct +{ + 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..ac5b861a5 --- /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 Swag\PayPal\RestApi\PayPalApiStruct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema(schema: 'paypal_agentic_commerce_v1_value_delivery_date_preference_value')] +class DeliveryDatePreferenceValue extends PayPalApiStruct +{ + 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..22012f322 --- /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 Swag\PayPal\RestApi\PayPalApiStruct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema( + schema: 'paypal_agentic_commerce_v1_value_delivery_instructions_value', + required: ['instructions'] +)] +class DeliveryInstructionsValue extends PayPalApiStruct +{ + /** + * 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..162cd06f1 --- /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 Swag\PayPal\RestApi\PayPalApiStruct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema( + schema: 'paypal_agentic_commerce_v1_value_gift_message_value', + required: ['message'] +)] +class GiftMessageValue extends PayPalApiStruct +{ + /** + * 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..1d05c4249 --- /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 Swag\PayPal\RestApi\PayPalApiStruct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema( + schema: 'paypal_agentic_commerce_v1_value_gift_recipient_email_value', + required: ['email'] +)] +class GiftRecipientEmailValue extends PayPalApiStruct +{ + /** + * 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..1349a993c --- /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 Swag\PayPal\RestApi\PayPalApiStruct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema( + schema: 'paypal_agentic_commerce_v1_value_gift_recipient_name_value', + required: ['name'] +)] +class GiftRecipientNameValue extends PayPalApiStruct +{ + /** + * 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..b549ec1ce --- /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 Swag\PayPal\RestApi\PayPalApiStruct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema( + schema: 'paypal_agentic_commerce_v1_value_privacy_consent_value', + required: ['consented'] +)] +class PrivacyConsentValue extends PayPalApiStruct +{ + 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..d14af2705 --- /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 Swag\PayPal\RestApi\PayPalApiStruct; + +/** + * @experimental + */ +#[Package('checkout')] +#[OA\Schema( + schema: 'paypal_agentic_commerce_v1_value_terms_acceptance_value', + required: ['accepted', 'termsVersions'] +)] +class TermsAcceptanceValue extends PayPalApiStruct +{ + /** + * 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/Subscriber/ProductFilterSubscriber.php b/src/AgentCommerce/Subscriber/ProductFilterSubscriber.php new file mode 100644 index 000000000..a2434ede5 --- /dev/null +++ b/src/AgentCommerce/Subscriber/ProductFilterSubscriber.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\Subscriber; + +use Shopware\Core\Checkout\Cart\Price\Struct\CartPrice; +use Shopware\Core\Content\Product\Events\ProductGatewayCriteriaEvent; +use Shopware\Core\Content\ProductExport\Event\ProductExportProductCriteriaEvent; +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 Swag\PayPal\SwagPayPal; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * @internal + */ +#[Package('checkout')] +class ProductFilterSubscriber implements EventSubscriberInterface +{ + public static function getSubscribedEvents(): array + { + return [ + ProductGatewayCriteriaEvent::class => 'onProductGatewayCriteria', + ProductExportProductCriteriaEvent::class => 'onProductExportProductCriteria', + ]; + } + + 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())); + } + + public function onProductExportProductCriteria(ProductExportProductCriteriaEvent $event): void + { + if ($event->getProductExport()->getSalesChannel()->getTypeId() !== SwagPayPal::SALES_CHANNEL_TYPE_AGENT_COMMERCE) { + return; + } + + // Product Export for agent commerce should have net prices + $event->getSalesChannelContext()->setTaxState(CartPrice::TAX_STATE_NET); + } +} diff --git a/src/AgentCommerce/Subscriber/WebhookSubscriber.php b/src/AgentCommerce/Subscriber/WebhookSubscriber.php new file mode 100644 index 000000000..f1205a9f2 --- /dev/null +++ b/src/AgentCommerce/Subscriber/WebhookSubscriber.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\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, + ) { + } + + 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, + ]; + } + + // @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/Tax/AgenticTaxDetector.php b/src/AgentCommerce/Tax/AgenticTaxDetector.php new file mode 100644 index 000000000..b662eb99c --- /dev/null +++ b/src/AgentCommerce/Tax/AgenticTaxDetector.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\Tax; + +use Shopware\Core\Checkout\Cart\Price\Struct\CartPrice; +use Shopware\Core\Checkout\Cart\Tax\AbstractTaxDetector; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\System\Country\CountryEntity; +use Shopware\Core\System\SalesChannel\SalesChannelContext; +use Symfony\Component\HttpFoundation\RequestStack; + +/** + * @internal + */ +#[Package('checkout')] +class AgenticTaxDetector extends AbstractTaxDetector +{ + public function __construct( + private readonly AbstractTaxDetector $decorated, + private readonly RequestStack $requestStack, + ) { + } + + public function getDecorated(): AbstractTaxDetector + { + return $this->decorated; + } + + public function useGross(SalesChannelContext $context): bool + { + $routeScope = $this->requestStack->getMainRequest()?->attributes->get('_routeScope'); + if (\is_array($routeScope) && \in_array('paypal-agent', $routeScope, true)) { + return false; + } + + return $this->getDecorated()->useGross($context); + } + + public function isNetDelivery(SalesChannelContext $context): bool + { + return $this->getDecorated()->isNetDelivery($context); + } + + public function getTaxState(SalesChannelContext $context): string + { + $routeScope = $this->requestStack->getMainRequest()?->attributes->get('_routeScope'); + if (!\is_array($routeScope) || !\in_array('paypal-agent', $routeScope, true)) { + return $this->getDecorated()->getTaxState($context); + } + + if ($this->isNetDelivery($context)) { + return CartPrice::TAX_STATE_FREE; + } + + if ($this->useGross($context)) { + return CartPrice::TAX_STATE_GROSS; + } + + return CartPrice::TAX_STATE_NET; + } + + public function isCompanyTaxFree(SalesChannelContext $context, CountryEntity $shippingLocationCountry): bool + { + return $this->getDecorated()->isCompanyTaxFree($context, $shippingLocationCountry); + } +} 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/Util/FaviconLoader.php b/src/AgentCommerce/Util/FaviconLoader.php new file mode 100644 index 000000000..3e4a6609e --- /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\SalesChannelContextServiceInterface; +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 SalesChannelContextServiceInterface $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..ab6ff478e --- /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 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 + */ +#[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..a0eaecb5e --- /dev/null +++ b/src/AgentCommerce/Util/PayPalCartTransformer.php @@ -0,0 +1,385 @@ + + * 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\Cart\LineItem\LineItemCollection; +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\Content\Product\ProductEntity; +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\Locale\LocaleCollection; +use Shopware\Core\System\Locale\LocaleEntity; +use Shopware\Core\System\SalesChannel\SalesChannelContext; +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; + +/** + * @internal + */ +#[Package('checkout')] +class PayPalCartTransformer +{ + /** + * @param EntityRepository $productRepository + * @param EntityRepository $countryRepository + * @param EntityRepository $localeRepository + */ + public function __construct( + private readonly EntityRepository $productRepository, + private readonly EntityRepository $countryRepository, + private readonly AbstractShippingMethodRoute $shippingMethodRoute, + private readonly ValidationIssues $validationIssues, + private readonly EntityRepository $localeRepository, + ) { + } + + 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')); + $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) { + // TODO: for now we remove all non selected shipping methods + // TODO: paypal needs prices for all shipping methods, otherwise it will fail + // TODO: only the selected shipping method has a calculated price (rule-system issue) + // TODO: should be removed, once we figure out a solution + if (!\array_key_exists($shippingMethod->getId(), $selectedMethods)) { + continue; + } + + $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; + } + + // @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); + + $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)]) + ); + + /** @var ProductEntity[] $restockProducts */ + $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'); + 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'); // @phpstan-ignore method.deprecated + 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->setAddressLine2($addressEntity->__isset('additionalAddressLine1') ? $addressEntity->getAdditionalAddressLine1() : null); + $address->setAdminArea1($addressEntity->__isset('countryState') ? $addressEntity->getCountryState()?->getShortCode() : 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(); + + $promotions = $cart->getLineItems()->filterFlatByType(LineItem::PROMOTION_LINE_ITEM_TYPE); + $promotionDiscount = (new LineItemCollection($promotions))->getPrices()->sum(); + + $subtotal = new Money(); + $subtotal->setValue((string) ($cartPrice->getPositionPrice() - $promotionDiscount->getTotalPrice())); + $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); + + $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; + } + + /** + * @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')); + $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..b1f7d99e5 --- /dev/null +++ b/src/AgentCommerce/Util/ShopwareCartTransformer.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\Util; + +use Shopware\Core\Checkout\Cart\LineItem\LineItem; +use Shopware\Core\Checkout\Cart\LineItemFactoryHandler\LineItemFactoryInterface; +use Shopware\Core\Checkout\Customer\Aggregate\CustomerGroup\CustomerGroupCollection; +use Shopware\Core\Checkout\Promotion\Cart\PromotionItemBuilder; +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\OrFilter; +use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\SuffixFilter; +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\SalesChannel\Entity\SalesChannelRepository; +use Shopware\Core\System\SalesChannel\SalesChannelContext; +use Shopware\Core\System\Salutation\SalutationCollection; +use Shopware\Core\System\Salutation\SalutationDefinition; +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 + */ +#[Package('checkout')] +class ShopwareCartTransformer +{ + /** + * @param SalesChannelRepository $countryRepository + * @param EntityRepository $salutationRepository + * @param EntityRepository $groupRepository + */ + public function __construct( + private readonly SalesChannelRepository $countryRepository, + private readonly EntityRepository $salutationRepository, + private readonly EntityRepository $groupRepository, + private readonly LineItemFactoryInterface $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, SalesChannelContext $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->getContext())->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, SalesChannelContext $context): array + { + $criteria = (new Criteria())->addFilter(new EqualsFilter('iso', $address->getCountryCode())); + + if ($address->getAdminArea1()) { + $criteria->getAssociation('states') + ->setLimit(1) + ->addFilter(new OrFilter([ + new EqualsFilter('shortCode', $address->getAdminArea1()), + new SuffixFilter('shortCode', '-' . $address->getAdminArea1()), + ])); + } + + $country = $this->countryRepository->search($criteria, $context)->first(); + if (!$country instanceof CountryEntity) { + throw AgentException::requiredFieldInvalid('address.countryCode', 'Country not found'); + } + + $criteria = (new Criteria())->addFilter(new EqualsFilter('salutationKey', SalutationDefinition::NOT_SPECIFIED)); + $salutationId = $this->salutationRepository->searchIds($criteria, $context->getContext())->firstId(); + + return [ + 'id' => Uuid::randomHex(), + 'salutationId' => $salutationId, + 'countryId' => $country->getId(), + 'countryStateId' => $country->getStates()?->first()?->getId(), + 'firstName' => $name->getGivenName(), + 'lastName' => $name->getSurname(), + 'zipcode' => $address->getPostalCode(), + 'city' => $address->getAdminArea2(), + 'street' => $address->getAddressLine1(), + 'additionalAddressLine1' => $address->getAddressLine2(), + '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/Constraint/PayPalExternalId.php b/src/AgentCommerce/Validation/Constraint/PayPalExternalId.php new file mode 100644 index 000000000..8b3246021 --- /dev/null +++ b/src/AgentCommerce/Validation/Constraint/PayPalExternalId.php @@ -0,0 +1,26 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Validation\Constraint; + +use Shopware\Core\Framework\Log\Package; +use Symfony\Component\Validator\Constraint; + +/** + * @internal + */ +#[Package('checkout')] +class PayPalExternalId extends Constraint +{ + public const NO_VALID_EXTERNAL_ID = 'c4d1d3a0-7e2f-4b7a-9b5d-4f99eecb6e21'; + + protected const ERROR_NAMES = [ + self::NO_VALID_EXTERNAL_ID => 'NO_VALID_EXTERNAL_ID', + ]; + + public string $message = 'external_id must contain at least one PayPal:* entry.'; +} diff --git a/src/AgentCommerce/Validation/Constraint/PayPalExternalIdValidator.php b/src/AgentCommerce/Validation/Constraint/PayPalExternalIdValidator.php new file mode 100644 index 000000000..ac6f9b026 --- /dev/null +++ b/src/AgentCommerce/Validation/Constraint/PayPalExternalIdValidator.php @@ -0,0 +1,42 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\AgentCommerce\Validation\Constraint; + +use Shopware\Core\Framework\Log\Package; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\ConstraintValidator; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; + +/** + * @internal + */ +#[Package('checkout')] +class PayPalExternalIdValidator extends ConstraintValidator +{ + public function validate(mixed $value, Constraint $constraint): void + { + if (!$constraint instanceof PayPalExternalId) { + throw new UnexpectedTypeException($constraint, PayPalExternalId::class); + } + + if (!\is_array($value)) { + return; + } + + foreach ($value as $entry) { + if (\is_string($entry) && str_starts_with($entry, 'PayPal:')) { + return; + } + } + + $this->context + ->buildViolation($constraint->message) + ->setCode(PayPalExternalId::NO_VALID_EXTERNAL_ID) + ->addViolation(); + } +} 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/AgentCommerce/Validation/ValidationIssues.php b/src/AgentCommerce/Validation/ValidationIssues.php new file mode 100644 index 000000000..e2c3fbb14 --- /dev/null +++ b/src/AgentCommerce/Validation/ValidationIssues.php @@ -0,0 +1,171 @@ + + * 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 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 +{ + /** + * @internal + */ + public function __construct( + private readonly AbstractTranslator $translator, + ) { + } + + public function outOfStock(LineItem $item, ?ProductEntity $restockProduct, CurrencyEntity $currency): ValidationIssue + { + $stock = $item->getPayloadValue('stock'); + + $builder = new ValidationIssueBuilder(); + // @phpstan-ignore method.resultUnused + $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); + + $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 4fccbb35b..27b5aa2cd 100644 --- a/src/Checkout/SalesChannel/MethodEligibilityRoute.php +++ b/src/Checkout/SalesChannel/MethodEligibilityRoute.php @@ -71,7 +71,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..fadbd093d 100644 --- a/src/DevOps/Command/GenerateOpenApi.php +++ b/src/DevOps/Command/GenerateOpenApi.php @@ -75,6 +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 . '/src/AgentCommerce/Struct'), ])?->toJson(); if ($openApi === null) { @@ -106,6 +107,7 @@ 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'), ])?->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 093a20f1e..70bcf9645 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": { @@ -7085,6 +7125,1831 @@ } }, "type": "object" + }, + "paypal_agentic_commerce_v1_address": { + "required": [ + "countryCode" + ], + "properties": { + "address_line_1": { + "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_line_2": { + "type": "string", + "maxLength": 300, + "minLength": 0 + }, + "admin_area_1": { + "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_area_2": { + "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": [ + "self", + "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 0c168e44b..18f5d0e4d 100644 --- a/src/Resources/Schema/StoreApi/openapi.json +++ b/src/Resources/Schema/StoreApi/openapi.json @@ -5544,6 +5544,1831 @@ } }, "type": "object" + }, + "paypal_agentic_commerce_v1_address": { + "required": [ + "countryCode" + ], + "properties": { + "address_line_1": { + "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_line_2": { + "type": "string", + "maxLength": 300, + "minLength": 0 + }, + "admin_area_1": { + "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_area_2": { + "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": [ + "self", + "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/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..17f2a9a57 --- /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.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: { + /* 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..8414b9432 --- /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..3f5e71906 --- /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,31 @@ +@import "~scss/variables"; + +.swag-paypal-settings-webhook { + &__title { + display: grid; + grid-template-columns: min-content min-content; + gap: 12px; + place-items: center; + } + + &__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.csv.twig b/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/static/product-export/body.csv.twig new file mode 100644 index 000000000..bdf511cd4 --- /dev/null +++ b/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/static/product-export/body.csv.twig @@ -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.csv.twig b/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/static/product-export/header.csv.twig new file mode 100644 index 000000000..789066371 --- /dev/null +++ b/src/Resources/app/administration/src/module/swag-paypal-agent-commerce/static/product-export/header.csv.twig @@ -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 f67c77f99..397522864 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 @@ -95,6 +95,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 e988ed455..2b790a7d4 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 @@ -95,6 +95,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/openapi.d.ts b/src/Resources/app/administration/src/types/openapi.d.ts index 48c46ffdb..578abaeb4 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; @@ -1434,6 +1440,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_line_1?: string; + address_line_2?: 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_area_1?: string; + /** @description A city, town, or village. Smaller than admin_area_level_1. */ + admin_area_2?: 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: "self" | "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; @@ -2162,4 +2875,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/app/administration/src/types/system-config.ts b/src/Resources/app/administration/src/types/system-config.ts index c0adca840..c5067a2c7 100644 --- a/src/Resources/app/administration/src/types/system-config.ts +++ b/src/Resources/app/administration/src/types/system-config.ts @@ -74,6 +74,7 @@ export declare type SystemConfig = { 'SwagPayPal.settings.crossBorderMessagingEnabled'?: boolean; 'SwagPayPal.settings.crossBorderBuyerCountry'?: typeof COUNTRY_OVERRIDES[number] | null; + 'SwagPayPal.settings.agentCommerceOnboarded'?: string; 'SwagPayPal.settings.isLocalEnvironment'?: boolean; }; 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.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/src/Resources/config/services/agent_commerce.xml b/src/Resources/config/services/agent_commerce.xml new file mode 100644 index 000000000..36adb70a6 --- /dev/null +++ b/src/Resources/config/services/agent_commerce.xml @@ -0,0 +1,147 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 a33f734f1..5df1de847 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'; public const IS_LOCAL_ENVIRONMENT = self::SYSTEM_CONFIG_DOMAIN . 'isLocalEnvironment'; /** 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 cc580ba72..62b67222d 100644 --- a/src/Util/IntrospectionProcessor.php +++ b/src/Util/IntrospectionProcessor.php @@ -15,10 +15,12 @@ use Shopware\Core\Framework\HttpException; use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\ShopwareHttpException; +use Swag\PayPal\AgentCommerce\Exception\AgentHttpException; use Shopware\Core\Framework\Validation\Exception\ConstraintViolationException; 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; /** @@ -54,7 +56,6 @@ public function __invoke(LogRecord $record): LogRecord return $record; } - /** @var Trace[] $traces */ $traces = $this->getBacktrace(); $extra = []; @@ -128,7 +129,7 @@ public function __invoke(LogRecord $record): LogRecord } /** - * @return Trace + * @return Trace[] */ protected function getBacktrace(): array { @@ -147,6 +148,10 @@ private function exceptionToContext(\Throwable $exception): array { $context = ['message' => $exception->getMessage()]; + 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/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/Exception/AgentExceptionTest.php b/tests/AgentCommerce/Exception/AgentExceptionTest.php new file mode 100644 index 000000000..c425658d0 --- /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\Test\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..f01ccb601 --- /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\Test\AgentCommerce\Exception; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Shopware\Core\Framework\Log\Package; +use Swag\PayPal\AgentCommerce\Exception\AgentHttpException; +use Swag\PayPal\AgentCommerce\Struct\V1\AgentErrorDetail; +use Swag\PayPal\AgentCommerce\Struct\V1\AgentErrorDetailCollection; + +/** + * @internal + */ +#[Package('checkout')] +#[CoversClass(AgentHttpException::class)] +class AgentHttpExceptionTest extends TestCase +{ + public function testPublicAPI(): void + { + $detail1 = new AgentErrorDetail(); + $detail1->assign([ + '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/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..3f589c90a --- /dev/null +++ b/tests/AgentCommerce/HoneyWebhookServiceTest.php @@ -0,0 +1,693 @@ + + * 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 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; +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\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\Exception\JWTException; +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::createDefaultContext(); + $salesChannel = self::createSalesChannel(); + $salesChannelRepository = $this->createMock(EntityRepository::class); + $salesChannelRepository + ->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(static::once()) + ->method('request') + ->with('POST', 'webhooks/sw/install') + ->willReturnCallback(function (string $method, string $url, array $options) use ($salesChannel) { + $jwt = $this->parseToken($options['body'])->claims()->all(); + + 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(static::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(static::once()) + ->method('getRouteCollection') + ->willReturn($routeCollection); + + $configServiceMock = $this->createMock(SystemConfigService::class); + $configServiceMock + ->expects(static::once()) + ->method('get') + ->with(Settings::AGENT_COMMERCE_ONBOARDED) + ->willReturn(false); + $configServiceMock + ->expects(static::once()) + ->method('set') + ->with(Settings::AGENT_COMMERCE_ONBOARDED, true); + + $loggerMock = $this->createMock(LoggerInterface::class); + $loggerMock + ->expects(static::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(static::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(static::once()) + ->method('request') + ->with('POST', 'webhooks/sw/uninstall') + ->willReturnCallback(function (string $method, string $url, array $options) { + $jwt = $this->parseToken($options['body'])->claims()->all(); + + 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(static::once()) + ->method('get') + ->with(Settings::AGENT_COMMERCE_ONBOARDED) + ->willReturn('eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdG9yZU5hbWUiOiJTYWxlc0NoYW5uZWwgbmFtZSIsInN0b3JlVXJsIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS8iLCJjb3VudHJ5IjoiREUiLCJjdXJyZW5jeSI6IkVVUiIsImZhdkljb24iOiJodHRwczovL2xvY2FsaG9zdC9mYXZpY29uLmljbyIsInNoaXBwaW5nQ291bnRyaWVzIjp7IjAxOTk4MGY5NDI2YzcxNmJhYTUzYmVmY2NjODRjNWM2IjoiREUiLCIwMTk5ODBmOTQyNmM3MTZiYWE1M2JlZmNjZDI4Y2Q3ZiI6IlVLIn0sInBheXBhbE1lcmNoYW50SWQiOiJTb21lTWVyY2hhbnRJZCIsInNob3B3YXJlTWVyY2hhbnRJZCI6IjAxOTk4MGY5NDI2YzcxNmJhYTUzYmVmY2QwODc5ZmI0IiwiY2F0YWxvZ0Rvd25sb2FkVXJsIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS90ZXN0L3BhdGgvZXhwb3J0In0.3K5rXCZGBgNFWOmZwTkVOV5AhCrr8VKgAS5ZPqsKeHI'); + $configServiceMock + ->expects(static::once()) + ->method('delete') + ->with(Settings::AGENT_COMMERCE_ONBOARDED); + + $loggerMock = $this->createMock(LoggerInterface::class); + $loggerMock + ->expects(static::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::createDefaultContext(); + $salesChannel = self::createSalesChannel(); + $salesChannelRepository = $this->createMock(EntityRepository::class); + $salesChannelRepository + ->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(static::exactly(2)) + ->method('request') + ->willReturnCallback(function (string $method, string $url, array $options) use ($salesChannel) { + $jwt = $this->parseToken($options['body'])->claims()->all(); + + 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(static::once()) + ->method('set') + ->with(Settings::AGENT_COMMERCE_ONBOARDED); + + $loggerMock = $this->createMock(LoggerInterface::class); + $loggerMock + ->expects(static::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::createDefaultContext(); + $salesChannel = self::createSalesChannel(); + $salesChannelRepository = $this->createMock(EntityRepository::class); + $salesChannelRepository + ->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(static::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(static::never()) + ->method('set') + ->with(Settings::AGENT_COMMERCE_ONBOARDED, true); + + $loggerMock = $this->createMock(LoggerInterface::class); + $loggerMock + ->expects(static::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(static::never()) + ->method('request'); + + $configServiceMock = $this->createMock(SystemConfigService::class); + $configServiceMock + ->expects(static::once()) + ->method('get') + ->with(Settings::AGENT_COMMERCE_ONBOARDED) + ->willReturn(null); + $configServiceMock + ->expects(static::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::createDefaultContext()); + if ($salesChannel) { + $searchResult = new EntitySearchResult('sales_channel', 1, new SalesChannelCollection([$salesChannel]), null, new Criteria(), Context::createDefaultContext()); + } + + $context = Context::createDefaultContext(); + $salesChannel = self::createSalesChannel(); + $salesChannelRepository = $this->createMock(EntityRepository::class); + $salesChannelRepository + ->expects(static::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(static::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::createDefaultContext(); + $salesChannel = self::createSalesChannel(); + $salesChannelRepository = $this->createMock(EntityRepository::class); + $salesChannelRepository + ->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(static::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(static::once()) + ->method('getRouteCollection') + ->willReturn($routeCollection); + + $configServiceMock = $this->createMock(SystemConfigService::class); + $configServiceMock + ->expects(static::once()) + ->method('get') + ->with(Settings::AGENT_COMMERCE_ONBOARDED) + ->willReturn(null); + $configServiceMock + ->expects(static::once()) + ->method('delete') + ->with(Settings::AGENT_COMMERCE_ONBOARDED); + + $loggerMock = $this->createMock(LoggerInterface::class); + $loggerMock + ->expects(static::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(static::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(static::once()) + ->method('get') + ->with(Settings::AGENT_COMMERCE_ONBOARDED) + ->willReturn('eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdG9yZU5hbWUiOiJTYWxlc0NoYW5uZWwgbmFtZSIsInN0b3JlVXJsIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS8iLCJjb3VudHJ5IjoiREUiLCJjdXJyZW5jeSI6IkVVUiIsImZhdkljb24iOiJodHRwczovL2xvY2FsaG9zdC9mYXZpY29uLmljbyIsInNoaXBwaW5nQ291bnRyaWVzIjp7IjAxOTk4MGY5NDI2YzcxNmJhYTUzYmVmY2NjODRjNWM2IjoiREUiLCIwMTk5ODBmOTQyNmM3MTZiYWE1M2JlZmNjZDI4Y2Q3ZiI6IlVLIn0sInBheXBhbE1lcmNoYW50SWQiOiJTb21lTWVyY2hhbnRJZCIsInNob3B3YXJlTWVyY2hhbnRJZCI6IjAxOTk4MGY5NDI2YzcxNmJhYTUzYmVmY2QwODc5ZmI0IiwiY2F0YWxvZ0Rvd25sb2FkVXJsIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS90ZXN0L3BhdGgvZXhwb3J0In0.3K5rXCZGBgNFWOmZwTkVOV5AhCrr8VKgAS5ZPqsKeHI'); + $configServiceMock + ->expects(static::never()) + ->method('set'); + + $loggerMock = $this->createMock(LoggerInterface::class); + $loggerMock + ->expects(static::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::createDefaultContext(); + $salesChannel = self::createSalesChannel(); + $salesChannelRepository = $this->createMock(EntityRepository::class); + $salesChannelRepository + ->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(static::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(static::once()) + ->method('getRouteCollection') + ->willReturn($routeCollection); + + $configServiceMock = $this->createMock(SystemConfigService::class); + $configServiceMock + ->expects(static::once()) + ->method('get') + ->with(Settings::AGENT_COMMERCE_ONBOARDED) + ->willReturn(false); + $configServiceMock + ->expects(static::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; + } + + 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 new file mode 100644 index 000000000..089790b23 --- /dev/null +++ b/tests/AgentCommerce/Routing/AgentContextResolverListenerTest.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\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 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($this->createMock(HttpKernelInterface::class), function (): void {}, $request, HttpKernelInterface::MAIN_REQUEST); + + $resolver = $this->createMock(RequestContextResolverInterface::class); + $resolver + ->expects(static::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..301812434 --- /dev/null +++ b/tests/AgentCommerce/Routing/AgentRequestContextResolverTest.php @@ -0,0 +1,559 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\Test\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\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 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 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 RouteScopeRegistry([new AgentRouteScope()]), + $this->createMock(SalesChannelContextService::class), + ); + + $this->expectExceptionObject(AgentException::unauthorized('Missing Authorization header')); + + $resolver->resolve($request); + } + + public function testResolveWithWrongPublicJWT(): void + { + $jwt = self::encodeJWT( + ['PayPal:MERCHANT_ID'], + new \DateTimeImmutable(), + new \DateTimeImmutable('+1 hour'), + ['cart', 'checkout'], + 'SALES_CHANNEL_ID' + ); + + $request = new Request(); + $request->headers->set('Authorization', 'Bearer ' . $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(static::once()) + ->method('search') + ->willReturn(self::createSearchResult($export)); + + $resolver = new AgentRequestContextResolver( + $this->createMock(DataValidator::class), + $entityRepository, + 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( + ['PayPal:MERCHANT_ID'], + $iat, + $exp, + ['cart', 'checkout'], + $salesChannelId, + ); + + $request = new Request(); + $request->headers->set('Authorization', 'Bearer ' . $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); + + static::assertInstanceOf(AgentRequestContextResolver::class, $resolver); + + $resolver->resolve($request); + } + + public function testResolveWithWrongJWTHeader(): void + { + $request = new Request(); + $request->headers->set('Authorization', 'Bearer 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 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', 'Bearer ' . $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); + + static::assertInstanceOf(AgentRequestContextResolver::class, $resolver); + + $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' => ['PayPal:MERCHANT_ID'], 'iat' => new \DateTimeImmutable(), 'exp' => new \DateTimeImmutable('+1 hour'), 'scope' => []]]; + yield 'salesChannelId not valid uuid' => [['paypalMerchantId' => ['PayPal:MERCHANT_ID'], 'iat' => new \DateTimeImmutable(), 'exp' => new \DateTimeImmutable('+1 hour'), 'scope' => [], 'shopwareMerchantId' => 'random_string']]; + } + + public function testResolveWithWrongAgentScopeInRoute(): void + { + $jwt = self::encodeJWT( + ['PayPal:MERCHANT_ID'], + new \DateTimeImmutable(), + new \DateTimeImmutable('+1 hour'), + ['cart', 'checkout'], + 'SALES_CHANNEL_ID' + ); + + $request = new Request(); + $request->headers->set('Authorization', 'Bearer ' . $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(static::once()) + ->method('search') + ->willReturn(self::createSearchResult($export)); + + $resolver = new AgentRequestContextResolver( + $this->createMock(DataValidator::class), + $entityRepository, + 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( + ['PayPal:MERCHANT_ID'], + $iat, + $exp, + ['these', 'are', 'wrong', 'scopes'], + 'SALES_CHANNEL_ID' + ); + + $request = Request::create('/CART-12345678912345678912345678912345/foo-bar'); + $request->headers->set('Authorization', 'Bearer ' . $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(static::once()) + ->method('search') + ->with(static::isInstanceOf(Criteria::class), $expectedContext) + ->willReturn($productExportResult); + + $contextService = $this->createMock(SalesChannelContextService::class); + $contextService + ->expects(static::once()) + ->method('get') + ->willReturn( + Generator::createSalesChannelContext($expectedContext) + ); + + $resolver = new AgentRequestContextResolver( + $this->createMock(DataValidator::class), + $repo, + 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(['PayPal:MERCHANT_ID'], $iat, $exp, ['cart', 'checkout'], 'SALES_CHANNEL_ID'); + + $request = new Request(); + $request->headers->set('Authorization', 'Bearer ' . $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(static::once()) + ->method('search') + ->willReturn(self::createSearchResult($export)); + + $salesChannelContext = Generator::createSalesChannelContext(); + + $salesChannelMock = $this->createMock(SalesChannelContextService::class); + $salesChannelMock + ->expects(static::once()) + ->method('get') + ->willReturn($salesChannelContext); + + $resolver = new AgentRequestContextResolver( + $this->createMock(DataValidator::class), + $entityRepository, + 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|non-empty-string[]|null $paypalMerchantId + * @param list|null $scopes + * @param non-empty-string|null $salesChannelId + */ + private static function encodeJWT(mixed $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', $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..5c6f254f1 --- /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\Test\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(static::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..78934d657 --- /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\Test\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..329928393 --- /dev/null +++ b/tests/AgentCommerce/SalesChannel/CheckoutRouteTest.php @@ -0,0 +1,337 @@ + + * 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\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\AsyncPaymentTransactionStruct; +use Shopware\Core\Checkout\Payment\PaymentException; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\ArrayStruct; +use Shopware\Core\Framework\Validation\DataBag\RequestDataBag; +use Shopware\Core\Test\Generator; +use Swag\PayPal\AgentCommerce\Exception\AgentException; +use Swag\PayPal\AgentCommerce\SalesChannel\CheckoutRoute; +use Swag\PayPal\AgentCommerce\Struct\V1\PaymentMethod; +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\Common\Link; +use Swag\PayPal\RestApi\V2\Api\Common\LinkCollection; +use Swag\PayPal\RestApi\V2\Api\Order; +use Swag\PayPal\RestApi\V2\PaymentStatusV2; +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::createSalesChannelContext()); + } + + public function testCheckoutWithEmptyCart(): void + { + $token = 'CART-TOKEN'; + + $cartService = $this->createMock(CartService::class); + $cartService + ->expects(static::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::createSalesChannelContext()); + } + + public function testCheckoutWithoutTransaction(): void + { + $token = 'CART-TOKEN'; + + $cartService = $this->createMock(CartService::class); + $cartService + ->expects(static::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(static::once()) + ->method('order') + ->willReturn($orderResponse); + + $payPalOrder = new Order(); + $payPalOrder->setId('PAYPAL-ORDER-ID'); + $payPalOrder->setStatus(PaymentStatusV2::ORDER_APPROVED); + + $orderResponse = $this->createMock(OrderResource::class); + $orderResponse + ->expects(static::once()) + ->method('get') + ->willReturn($payPalOrder); + + $route = new CheckoutRoute( + $orderRoute, + $cartService, + $orderResponse, + $this->createMock(PayPalPaymentHandler::class), + $this->createMock(PayPalCartTransformer::class) + ); + + $this->expectExceptionObject(AgentException::orderSystemError()); + + $request = new Request(content: \json_encode(['payment_method' => ['token' => 'PAYPAL-ORDER-ID']], \JSON_THROW_ON_ERROR)); + $route->checkout($token, $request, Generator::createSalesChannelContext()); + } + + public function testPayPalOrderNotApproved(): void + { + $token = 'CART-TOKEN'; + + $cartService = $this->createMock(CartService::class); + $cartService + ->expects(static::once()) + ->method('getCart') + ->with('TOKEN') + ->willReturn(Generator::createCart()); + + $link = new Link(); + $link->setRel(Link::RELATION_PAYER_ACTION); + $link->setHref('https://example.com/approve/order'); + + $payPalOrder = new Order(); + $payPalOrder->setId('PAYPAL-ORDER-ID'); + $payPalOrder->setStatus(PaymentStatusV2::ORDER_PAYER_ACTION_REQUIRED); + $payPalOrder->setLinks(new LinkCollection([$link])); + + $orderResponse = $this->createMock(OrderResource::class); + $orderResponse + ->expects(static::once()) + ->method('get') + ->willReturn($payPalOrder); + + $route = new CheckoutRoute( + $this->createMock(AbstractCartOrderRoute::class), + $cartService, + $orderResponse, + $this->createMock(PayPalPaymentHandler::class), + $this->createMock(PayPalCartTransformer::class) + ); + + $request = new Request(content: \json_encode(['payment_method' => ['token' => 'PAYPAL-ORDER-ID']], \JSON_THROW_ON_ERROR)); + $result = $route->checkout($token, $request, Generator::createSalesChannelContext()); + $object = $result->getObject(); + + static::assertInstanceOf(ArrayStruct::class, $object); + static::assertSame(PayPalCart::STATUS__INCOMPLETE, $object->get('status')); + static::assertSame(PayPalCart::VALIDATION_STATUS__INVALID, $object->get('validation_status')); + + static::assertInstanceOf(PaymentMethod::class, $object->get('payment_method')); + static::assertSame('PAYPAL-ORDER-ID', $object->get('payment_method')->getToken()); + static::assertSame('https://example.com/approve/order', $object->get('payment_method')->getApprovalUrl()); + } + + public function testCheckout(): void + { + $token = 'CART-TOKEN'; + + $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(static::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(static::once()) + ->method('order') + ->willReturn($orderResponse); + + $payPalOrder = new Order(); + $payPalOrder->setId('PAYPAL-ORDER-ID'); + + $orderResource = $this->createMock(OrderResource::class); + $orderResource + ->expects(static::once()) + ->method('get') + ->with('PAYPAL-ORDER-ID', $context->getSalesChannelId()) + ->willReturn($payPalOrder); + + $payPalCart = new PayPalCart(); + $payPalCart->setId('PAYPAL-ORDER-ID'); + $payPalOrder->setStatus(PaymentStatusV2::ORDER_APPROVED); + + $transformer = $this->createMock(PayPalCartTransformer::class); + $transformer + ->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(static::once()) + ->method('pay') + ->with(static::equalTo(new AsyncPaymentTransactionStruct($transaction, $order, '')), $requestDataBag, $context) + ->willThrowException(PaymentException::asyncProcessInterrupted($transaction->getId(), 'error message')); + + $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::assertNotNull($cart->getPaymentMethod()); + static::assertSame('PAYPAL-ORDER-ID', $cart->getPaymentMethod()->getToken()); + } + + public function testCheckoutWithRedirect(): void + { + $token = 'CART-TOKEN'; + + $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(static::once()) + ->method('getCart') + ->with('TOKEN') + ->willReturn($cart); + + $transaction = new OrderTransactionEntity(); + $transaction->setId('primary-order-transaction-id'); + + $order = new OrderEntity(); + $order->setTransactions(new OrderTransactionCollection([$transaction])); + + $orderRoute = $this->createMock(AbstractCartOrderRoute::class); + $orderRoute + ->expects(static::once()) + ->method('order') + ->willReturn(new CartOrderRouteResponse($order)); + + $payPalOrder = new Order(); + $payPalOrder->setId('PAYPAL-ORDER-ID'); + + $orderResource = $this->createMock(OrderResource::class); + $orderResource + ->expects(static::once()) + ->method('get') + ->with('PAYPAL-ORDER-ID', $context->getSalesChannelId()) + ->willReturn($payPalOrder); + + $payPalCart = new PayPalCart(); + $payPalCart->setId('PAYPAL-ORDER-ID'); + $payPalOrder->setStatus(PaymentStatusV2::ORDER_APPROVED); + $payPalCart->setValidationStatus(PayPalCart::VALIDATION_STATUS__VALID); + + $transformer = $this->createMock(PayPalCartTransformer::class); + $transformer + ->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(static::once()) + ->method('pay') + ->with(static::equalTo(new AsyncPaymentTransactionStruct($transaction, $order, '')), $requestDataBag, $context) + ->willReturn(new RedirectResponse('https://example.com/redirect-url')); + $paymentHandler + ->expects(static::once()) + ->method('finalize'); + + $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::assertSame(PayPalCart::VALIDATION_STATUS__VALID, $cart->getValidationStatus()); + + static::assertNotNull($cart->getPaymentMethod()); + static::assertSame('PAYPAL-ORDER-ID', $cart->getPaymentMethod()->getToken()); + } +} diff --git a/tests/AgentCommerce/SalesChannel/CreateCartRouteTest.php b/tests/AgentCommerce/SalesChannel/CreateCartRouteTest.php new file mode 100644 index 000000000..a103ad5aa --- /dev/null +++ b/tests/AgentCommerce/SalesChannel/CreateCartRouteTest.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\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\LineItem\LineItem; +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 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; + +/** + * @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(static::once()) + ->method('extractCustomerData') + ->willReturn(['valid-data']); + + $this->registerRoute + ->expects(static::once()) + ->method('register'); + + $this->contextService + ->method('get') + ->willReturn($salesChannelContext); + + $cart = new Cart(''); + $cart->add(new LineItem(Uuid::randomHex(), 'product', Uuid::randomHex())); + + $this->cartService + ->expects(static::once()) + ->method('createNew') + ->willReturn($cart); + $this->cartService + ->expects(static::once()) + ->method('add') + ->willReturn($cart); + + $this->shopwareCartTransformer + ->expects(static::once()) + ->method('getLineItems') + ->willReturn([]); + + $order = new Order(); + $order->setId('some-order-id'); + + $this->orderBuilder + ->expects(static::once()) + ->method('getOrderFromCart') + ->willReturn($order); + + $this->orderResource + ->expects(static::once()) + ->method('create') + ->willReturn($order); + + $payPalCart = new PayPalCart(); + $payPalCart->setValidationStatus(PayPalCart::VALIDATION_STATUS__VALID); + + $this->payPalCartTransformer + ->expects(static::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/DefaultRouteScopeTest.php b/tests/AgentCommerce/SalesChannel/DefaultRouteScopeTest.php new file mode 100644 index 000000000..30ec47132 --- /dev/null +++ b/tests/AgentCommerce/SalesChannel/DefaultRouteScopeTest.php @@ -0,0 +1,48 @@ + + * 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\TestCase; +use Swag\PayPal\AgentCommerce\Routing\AgentSource; +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 Symfony\Component\Routing\Attribute\Route; + +/** + * @internal + */ +class DefaultRouteScopeTest extends TestCase +{ + /** + * @var array> + */ + private static array $expectedDefaults = [ + CreateCartRoute::class => [AgentSource::SCOPE_CART], + GetCartRoute::class => [AgentSource::SCOPE_CART], + UpdateCartRoute::class => [AgentSource::SCOPE_CART], + CheckoutRoute::class => [AgentSource::SCOPE_CHECKOUT], + ]; + + public function testRoutesHaveCorrectScopeDefaults(): void + { + foreach (self::$expectedDefaults as $class => $expectedDefaults) { + $reflectionClass = new \ReflectionClass($class); + $attributes = $reflectionClass->getAttributes(Route::class); + + static::assertNotEmpty($attributes, \sprintf('No Route attribute found for class %s', $class)); + + /** @var Route $routeAttribute */ + $routeAttribute = $attributes[0]->newInstance(); + static::assertArrayHasKey('_agentScope', $routeAttribute->getDefaults()); + + static::assertEquals($expectedDefaults, $routeAttribute->getDefaults()['_agentScope'], \sprintf('Incorrect _agentScope defaults for class %s', $class)); + } + } +} diff --git a/tests/AgentCommerce/SalesChannel/GetCartRouteTest.php b/tests/AgentCommerce/SalesChannel/GetCartRouteTest.php new file mode 100644 index 000000000..8e8d4211c --- /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 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; + +/** + * @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..ba374dc37 --- /dev/null +++ b/tests/AgentCommerce/SalesChannel/Response/AgentCartResponseTest.php @@ -0,0 +1,35 @@ + + * 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\Response; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\ArrayStruct; +use Swag\PayPal\AgentCommerce\SalesChannel\Response\AgentCartResponse; +use Swag\PayPal\AgentCommerce\Struct\V1\PayPalCart; + +/** + * @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); + $responseObject = $response->getObject(); + + 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 new file mode 100644 index 000000000..5a3c6df2e --- /dev/null +++ b/tests/AgentCommerce/SalesChannel/Response/AgentResponseExceptionSubscriberTest.php @@ -0,0 +1,202 @@ + + * 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\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 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\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; +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 = $this->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 = (new AgentError())->assign($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 = $this->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 = (new AgentError())->assign($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 = $this->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 = $this->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 = (new AgentError())->assign($content); + + 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 = $this->createEvent($request, PaymentException::asyncProcessInterrupted(Uuid::randomHex(), 'Error message')); + + $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 = (new AgentError())->assign($content); + + static::assertSame('CHECKOUT__ASYNC_PAYMENT_PROCESS_INTERRUPTED', $error->getName()); + 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 = $this->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 = (new AgentError())->assign($content); + + static::assertSame('UNKNOWN_ERROR', $error->getName()); + static::assertSame('Generic error', $error->getMessage()); + static::assertSame(500, $error->getCode()); + static::assertSame('debug-id', $error->getDebugId()); + } + + private function createEvent(Request $request, \Throwable $e): ExceptionEvent + { + 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 new file mode 100644 index 000000000..13cc84e69 --- /dev/null +++ b/tests/AgentCommerce/SalesChannel/UpdateCartRouteTest.php @@ -0,0 +1,410 @@ + + * 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\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 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; + +/** + * @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(static::once()) + ->method('delete') + ->with([['id' => $customer->getId()]]); + + $this->cartService + ->expects(static::once()) + ->method('deleteCart'); + + $payPalCart = new PayPalCart(); + $payPalCart->setValidationStatus(PayPalCart::VALIDATION_STATUS__VALID); + $createResponse = new AgentCartResponse($payPalCart); + + $this->createCartRoute + ->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::assertInstanceOf(ArrayStruct::class, $responseObject); + static::assertSame(PayPalCart::STATUS__READY, $responseObject->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(static::never()) + ->method('delete'); + + $this->cartService + ->expects(static::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(static::never()) + ->method('delete'); + $this->customerRepository + ->expects(static::once()) + ->method('update') + ->with([$upsertData]); + + $this->shopwareCartTransformer + ->expects(static::once()) + ->method('extractCustomerData') + ->willReturn($customerData); + + $payPalCart = new PayPalCart(); + $payPalCart->setValidationStatus(PayPalCart::VALIDATION_STATUS__VALID); + $createResponse = new AgentCartResponse($payPalCart); + + $this->createCartRoute + ->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::assertInstanceOf(ArrayStruct::class, $responseObject); + static::assertSame(PayPalCart::STATUS__READY, $responseObject->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(static::once()) + ->method('delete') + ->with([['id' => $customer->getDefaultBillingAddressId()]]); + + $this->cartService + ->expects(static::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(static::never()) + ->method('delete'); + $this->customerRepository + ->expects(static::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(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::assertInstanceOf(ArrayStruct::class, $responseObject); + static::assertSame(PayPalCart::STATUS__READY, $responseObject->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(static::once()) + ->method('deleteCart'); + + $payPalCart = new PayPalCart(); + $payPalCart->setValidationStatus(PayPalCart::VALIDATION_STATUS__VALID); + $createResponse = new AgentCartResponse($payPalCart); + + $this->createCartRoute + ->expects(static::once()) + ->method('createCart') + ->willReturn($createResponse); + + $this->contextSwitchRoute + ->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::assertInstanceOf(ArrayStruct::class, $responseObject); + static::assertSame(PayPalCart::STATUS__READY, $responseObject->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(static::once()) + ->method('deleteCart'); + + $this->createCartRoute + ->expects(static::never()) + ->method('createCart'); + + $this->contextSwitchRoute + ->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::assertInstanceOf(ArrayStruct::class, $responseObject); + static::assertSame(PayPalCart::STATUS__READY, $responseObject->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(static::once()) + ->method('deleteCart'); + + $payPalCart = new PayPalCart(); + $payPalCart->setValidationStatus(PayPalCart::VALIDATION_STATUS__VALID); + $createResponse = new AgentCartResponse($payPalCart); + + $this->createCartRoute + ->expects(static::once()) + ->method('createCart') + ->willReturn($createResponse); + + $this->contextSwitchRoute + ->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::assertInstanceOf(ArrayStruct::class, $responseObject); + static::assertSame(PayPalCart::STATUS__READY, $responseObject->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..67e652e06 --- /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::createDefaultContext()); + + $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::createDefaultContext($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::createDefaultContext($originalSource)); + + $salesChannelContext = $this->createMock(SalesChannelContext::class); + $salesChannelContext + ->method('getContext') + ->willReturn(Context::createDefaultContext($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..b0a20ad31 --- /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::createDefaultContext(new AdminApiSource(Uuid::randomHex())), + [] + ); + + $salesChannelRepository = $this->createMock(EntityRepository::class); + $salesChannelRepository + ->expects(static::once()) + ->method('searchIds') + ->willReturnCallback(static function (Criteria $criteria) use ($deleteId, $activateId, $deactiveId) { + static::assertSame([$deleteId, $activateId, $deactiveId], $criteria->getIds()); + + $data = [ + $activateId => ['primaryKey' => $activateId, 'data' => []], + $deactiveId => ['primaryKey' => $deactiveId, 'data' => []], + ]; + + return new IdSearchResult(2, $data, $criteria, Context::createDefaultContext()); + }); + + $webhookResult = new HoneyWebhookResult(true, 'success message', null); + $webhook = $this->createMock(HoneyWebhookService::class); + $webhook + ->expects(static::once()) + ->method('register') + ->with($activateId) + ->willReturn($webhookResult); + $webhook + ->expects(static::exactly(2)) + ->method('deregister') + ->willReturn($webhookResult); + + $notificationRepository = $this->createMock(EntityRepository::class); + $notificationRepository + ->expects(static::exactly(3)) + ->method('create'); + + $subscriber = new WebhookSubscriber( + $salesChannelRepository, + $webhook, + $notificationRepository, + ); + + $subscriber->handleWebhookLifecycle($event); + } + + public function testEmptyWriteResult(): void + { + $salesChannelRepository = $this->createMock(EntityRepository::class); + $salesChannelRepository + ->expects(static::never()) + ->method('searchIds'); + + $webhook = $this->createMock(HoneyWebhookService::class); + $webhook + ->expects(static::never()) + ->method('register'); + $webhook + ->expects(static::never()) + ->method('deregister'); + + $notificationRepository = $this->createMock(EntityRepository::class); + $notificationRepository + ->expects(static::never()) + ->method('create'); + + $subscriber = new WebhookSubscriber( + $salesChannelRepository, + $webhook, + $notificationRepository, + ); + + $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 new file mode 100644 index 000000000..81c849561 --- /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\Test\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..26234c0ad --- /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\Test\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(static::once()) + ->method('load') + ->willReturn([]); + + $faviconLoader = new FaviconLoader( + $themeProviderMock, + $this->createMock(AbstractResolvedConfigLoader::class), + $this->createMock(SalesChannelContextService::class), + ); + + $faviconLoader->loadFaviconLink(Uuid::randomHex(), Context::createDefaultContext()); + } + + public function testLoadFaviconLink(): void + { + $themeId = Uuid::randomHex(); + $salesChannelId = Uuid::randomHex(); + $context = Context::createDefaultContext(); + + $themeProviderMock = $this->createMock(AbstractAvailableThemeProvider::class); + $themeProviderMock + ->expects(static::once()) + ->method('load') + ->willReturn([$salesChannelId => $themeId]); + + $salesChannelMock = $this->createMock(SalesChannelContext::class); + + $contextServiceMock = $this->createMock(SalesChannelContextService::class); + $contextServiceMock + ->expects(static::once()) + ->method('get') + ->willReturn($salesChannelMock); + + $configLoaderMock = $this->createMock(AbstractResolvedConfigLoader::class); + $configLoaderMock + ->expects(static::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::createDefaultContext(); + + $themeProviderMock = $this->createMock(AbstractAvailableThemeProvider::class); + $themeProviderMock + ->expects(static::once()) + ->method('load') + ->willReturn([$salesChannelId => $themeId]); + + $salesChannelMock = $this->createMock(SalesChannelContext::class); + + $contextServiceMock = $this->createMock(SalesChannelContextService::class); + $contextServiceMock + ->expects(static::once()) + ->method('get') + ->willReturn($salesChannelMock); + + $configLoaderMock = $this->createMock(AbstractResolvedConfigLoader::class); + $configLoaderMock + ->expects(static::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..7e7154093 --- /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\Test\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..dc800278c --- /dev/null +++ b/tests/AgentCommerce/Util/PayPalCartTransformerTest.php @@ -0,0 +1,766 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\Test\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\Aggregate\CountryState\CountryStateEntity; +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 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; + +/** + * @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), + $this->createMock(EntityRepository::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), + $this->createMock(EntityRepository::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::createDefaultContext() + ); + + $shippingRouteMock = $this->createMock(AbstractShippingMethodRoute::class); + $shippingRouteMock + ->expects(static::once()) + ->method('load') + ->willReturn(new ShippingMethodRouteResponse($result)); + + $transformer = new PayPalCartTransformer( + $this->createMock(EntityRepository::class), + $this->createMock(EntityRepository::class), + $shippingRouteMock, + $this->createMock(ValidationIssues::class), + $this->createMock(EntityRepository::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(), null, null), + 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); + + // TODO: reintroduce once we have a solution for providing all shipping methods including prices + // static::assertCount(3, $availableShippingMethods); + static::assertCount(2, $availableShippingMethods); + + static::assertInstanceOf(ShippingOption::class, $first); + static::assertInstanceOf(ShippingOption::class, $second); + // TODO: reintroduce once we have a solution for providing all shipping methods including prices + // static::assertInstanceOf(ShippingOption::class, $third); + static::assertNull($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()); + + // TODO: reintroduce once we have a solution for providing all shipping methods including prices + // 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), + $this->createMock(EntityRepository::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), + $this->createMock(EntityRepository::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), + $this->createMock(EntityRepository::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), + $this->createMock(EntityRepository::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), + $this->createMock(EntityRepository::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('200', $totals->getSubtotal()?->getValue()); + static::assertSame('EUR', $totals->getSubtotal()->getCurrencyCode()); + static::assertSame('15', $totals->getDiscount()?->getValue()); + static::assertSame('EUR', $totals->getDiscount()->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), + $this->createMock(EntityRepository::class), + ); + + $address = new CustomerAddressEntity(); + $address->setCountryId(Uuid::randomHex()); + + $transformer->convertAddress($address, ShippingAddress::class, Context::createDefaultContext()); + } + + public function testConvertNullAddress(): void + { + $transformer = new PayPalCartTransformer( + $this->createMock(EntityRepository::class), + $this->createMock(EntityRepository::class), + $this->createMock(AbstractShippingMethodRoute::class), + $this->createMock(ValidationIssues::class), + $this->createMock(EntityRepository::class), + ); + + static::assertNull($transformer->convertAddress(null, ShippingAddress::class, Context::createDefaultContext())); + } + + 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::createDefaultContext() + ); + + $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), + $this->createMock(EntityRepository::class), + ); + + $countryState = new CountryStateEntity(); + $countryState->setShortCode('DE-NW'); + + $address = new CustomerAddressEntity(); + $address->setCountryId(Uuid::randomHex()); + $address->setZipcode('12345'); + $address->setStreet('Mainstreet 1'); + $address->setCity('City 1'); + $address->setAdditionalAddressLine1('Address line 1'); + $address->setCountryState($countryState); + + $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); + + 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()); + + static::assertSame('Address line 1', $shippingAddress->getAddressLine2()); + static::assertSame('Address line 1', $billingAddress->getAddressLine2()); + + static::assertSame('DE', $shippingAddress->getCountryCode()); + static::assertSame('DE', $billingAddress->getCountryCode()); + + static::assertSame('DE-NW', $shippingAddress->getAdminArea1()); + static::assertSame('DE-NW', $billingAddress->getAdminArea1()); + } + + public function testConvertToValidationIssuesNoIssues(): void + { + $transformer = new PayPalCartTransformer( + $this->createMock(EntityRepository::class), + $this->createMock(EntityRepository::class), + $this->createMock(AbstractShippingMethodRoute::class), + $this->createMock(ValidationIssues::class), + $this->createMock(EntityRepository::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; + }); + + $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); + $context + ->method('getCurrency') + ->willReturn(new CurrencyEntity()); + $context + ->method('getContext') + ->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->setId(Uuid::randomHex()); + $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(); + $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); + + $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($lineItem), + 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), + $this->createMock(EntityRepository::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(), 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(), null, null), + new CalculatedPrice(5, 5, new CalculatedTaxCollection(), new TaxRuleCollection()) + ); + + $deliveries = new DeliveryCollection(); + $deliveries->add($delivery1); + $deliveries->add($delivery2); + + $lineItem1 = new LineItem(Uuid::randomHex(), LineItem::PRODUCT_LINE_ITEM_TYPE); + $lineItem1->setPrice(new CalculatedPrice(100, 100, new CalculatedTaxCollection(), new TaxRuleCollection())); + $lineItem2 = new LineItem(Uuid::randomHex(), LineItem::PRODUCT_LINE_ITEM_TYPE); + $lineItem2->setPrice(new CalculatedPrice(100, 100, new CalculatedTaxCollection(), new TaxRuleCollection())); + $promotion = new LineItem(Uuid::randomHex(), LineItem::PROMOTION_LINE_ITEM_TYPE); + $promotion->setPrice(new CalculatedPrice(-15, -15, new CalculatedTaxCollection(), new TaxRuleCollection())); + + $cart = new Cart(Uuid::randomHex()); + $cart->setPrice($cartPrice); + $cart->setDeliveries($deliveries); + $cart->setLineItems(new LineItemCollection([$lineItem1, $lineItem2, $promotion])); + + 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..3f8487269 --- /dev/null +++ b/tests/AgentCommerce/Util/ShopwareCartTransformerTest.php @@ -0,0 +1,207 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\Test\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\EntitySearchResult; +use Shopware\Core\Framework\DataAbstractionLayer\Search\IdSearchResult; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Uuid\Uuid; +use Shopware\Core\System\Country\Aggregate\CountryState\CountryStateCollection; +use Shopware\Core\System\Country\Aggregate\CountryState\CountryStateEntity; +use Shopware\Core\System\Country\CountryCollection; +use Shopware\Core\System\Country\CountryDefinition; +use Shopware\Core\System\Country\CountryEntity; +use Shopware\Core\System\SalesChannel\Entity\SalesChannelRepository; +use Shopware\Core\System\SalesChannel\SalesChannelContext; +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; + +/** + * @internal + */ +#[Package('checkout')] +#[CoversClass(ShopwareCartTransformer::class)] +class ShopwareCartTransformerTest extends TestCase +{ + public function testExtractCustomerData(): void + { + $overallRandomId = Uuid::randomHex(); + $idResult = new IdSearchResult(1, [$overallRandomId => ['primaryKey' => $overallRandomId, 'data' => []]], new Criteria(), Context::createDefaultContext()); + + $caId = Uuid::randomHex(); + $state1 = new CountryStateEntity(); + $state1->setId($caId); + $state1->setShortCode('CA'); + + $nyId = Uuid::randomHex(); + $state2 = new CountryStateEntity(); + $state2->setId($nyId); + $state2->setShortCode('US-NY'); + + $country1 = new CountryEntity(); + $country1->setId($overallRandomId); + $country1->setStates(new CountryStateCollection([$state1])); + $country2 = new CountryEntity(); + $country2->setId($overallRandomId); + $country2->setStates(new CountryStateCollection([$state2])); + + $salesChannelRepository = $this->createMock(SalesChannelRepository::class); + $salesChannelRepository + ->method('search') + ->willReturnOnConsecutiveCalls( + new EntitySearchResult(CountryDefinition::ENTITY_NAME, 1, new CountryCollection([$country1]), null, new Criteria(), Context::createDefaultContext()), + new EntitySearchResult(CountryDefinition::ENTITY_NAME, 1, new CountryCollection([$country2]), null, new Criteria(), Context::createDefaultContext()) + ); + + $repository = $this->createMock(EntityRepository::class); + $repository + ->method('searchIds') + ->willReturn($idResult); + + $transformer = new ShopwareCartTransformer( + $salesChannelRepository, + $repository, + $repository, + $this->createMock(ProductLineItemFactory::class), + $this->createMock(PromotionItemBuilder::class), + ); + + $customerData = $transformer->extractCustomerData((new PayPalCart())->assign(self::requestCustomerData()), $overallRandomId, $this->createMock(SalesChannelContext::class)); + + 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($caId, $customerData['shippingAddress']['countryStateId']); + static::assertArrayHasKey('billingAddress', $customerData); + static::assertSame($overallRandomId, $customerData['billingAddress']['salutationId']); + static::assertSame($overallRandomId, $customerData['billingAddress']['countryId']); + static::assertSame($nyId, $customerData['billingAddress']['countryStateId']); + + 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('Apt 4B', $customerData['shippingAddress']['additionalAddressLine1']); + static::assertSame('+1 12345-6789', $customerData['shippingAddress']['phoneNumber']); + + 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('Suite 789', $customerData['billingAddress']['additionalAddressLine1']); + 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(SalesChannelRepository::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(), $this->createMock(SalesChannelContext::class)); + } + + 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(SalesChannelRepository::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()); + + 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..44406f31c --- /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\Test\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/HasScopesTest.php b/tests/AgentCommerce/Validation/HasScopesTest.php new file mode 100644 index 000000000..8a63de463 --- /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\Test\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); + } +} diff --git a/tests/AgentCommerce/Validation/ValidationIssuesTest.php b/tests/AgentCommerce/Validation/ValidationIssuesTest.php new file mode 100644 index 000000000..ba4efcabf --- /dev/null +++ b/tests/AgentCommerce/Validation/ValidationIssuesTest.php @@ -0,0 +1,287 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPal\Test\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\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\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\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; + +/** + * @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 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 PromotionCartAddedInformationError::class => [new PromotionCartAddedInformationError(self::createLineItem()), $code]; + yield PromotionCartDeletedInformationError::class => [new PromotionCartDeletedInformationError(self::createLineItem()), $code]; + 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'), $code]; + yield ShippingMethodChangedError::class => [new ShippingMethodChangedError('foo', 'bar'), $code]; + } + + 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 01c320d6a..0de6cc02f 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/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/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/IntrospectionProcessorTest.php b/tests/Util/IntrospectionProcessorTest.php index a5e0f007c..67911b473 100644 --- a/tests/Util/IntrospectionProcessorTest.php +++ b/tests/Util/IntrospectionProcessorTest.php @@ -16,6 +16,9 @@ use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Validation\Exception\ConstraintViolationException; 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; @@ -278,6 +281,44 @@ public static function invokeWithExceptionDataProvider(): \Generator ]], ]; + 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', + ], + ], + ]], + ]; + yield 'ConstraintViolationException' => [ ['exception' => new ConstraintViolationException(new ConstraintViolationList([new ConstraintViolation( 'test message', diff --git a/tests/Util/Lifecycle/UpdateTest.php b/tests/Util/Lifecycle/UpdateTest.php index d3350903a..4692b6105 100644 --- a/tests/Util/Lifecycle/UpdateTest.php +++ b/tests/Util/Lifecycle/UpdateTest.php @@ -70,7 +70,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; /** @@ -526,8 +525,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( @@ -573,9 +574,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,