├── .gitattributes ├── composer.json └── src ├── Adapter └── DynamoDB │ ├── DynamoDBRepository.php │ └── DynamoDBShop.php ├── AppConfiguration.php ├── AppLifecycle.php ├── Authentication ├── RequestVerifier.php └── ResponseSigner.php ├── Context ├── ActionButton │ └── ActionButtonAction.php ├── ActionSource.php ├── ArrayStruct.php ├── Cart │ ├── CalculatedPrice.php │ ├── CalculatedTax.php │ ├── Cart.php │ ├── CartPrice.php │ ├── CartTransaction.php │ ├── Delivery.php │ ├── DeliveryDate.php │ ├── DeliveryPosition.php │ ├── Error.php │ ├── LineItem.php │ └── TaxRule.php ├── ContextResolver.php ├── Gateway │ ├── Checkout │ │ └── CheckoutGatewayAction.php │ ├── Context │ │ └── ContextGatewayAction.php │ └── InAppFeatures │ │ └── FilterAction.php ├── InAppPurchase │ ├── HasMatchingDomain.php │ ├── HasValidRSAJWKSignature.php │ ├── InAppPurchase.php │ ├── InAppPurchaseProvider.php │ └── SBPStoreKeyFetcher.php ├── Module │ └── ModuleAction.php ├── Order │ ├── Order.php │ ├── OrderCustomer.php │ ├── OrderDelivery.php │ ├── OrderLineItem.php │ ├── OrderTransaction.php │ └── StateMachineState.php ├── Payment │ ├── PaymentCaptureAction.php │ ├── PaymentFinalizeAction.php │ ├── PaymentPayAction.php │ ├── PaymentRecurringAction.php │ ├── PaymentValidateAction.php │ ├── RecurringData.php │ ├── Refund.php │ ├── RefundAction.php │ └── RefundTransactionCapture.php ├── Response │ ├── Customer │ │ ├── AddressResponseStruct.php │ │ └── CustomerResponseStruct.php │ └── ResponseStruct.php ├── SalesChannelContext │ ├── Address.php │ ├── Country.php │ ├── CountryState.php │ ├── Currency.php │ ├── Customer.php │ ├── PaymentMethod.php │ ├── RoundingConfig.php │ ├── SalesChannel.php │ ├── SalesChannelContext.php │ ├── SalesChannelDomain.php │ ├── Salutation.php │ ├── ShippingLocation.php │ ├── ShippingMethod.php │ └── TaxInfo.php ├── Storefront │ ├── StorefrontAction.php │ └── StorefrontClaims.php ├── TaxProvider │ └── TaxProviderAction.php ├── Trait │ └── CustomFieldsAware.php └── Webhook │ └── WebhookAction.php ├── Event ├── AbstractAppLifecycleEvent.php ├── BeforeRegistrationCompletedEvent.php ├── BeforeRegistrationStartsEvent.php ├── BeforeShopActivateEvent.php ├── BeforeShopDeactivatedEvent.php ├── BeforeShopDeletionEvent.php ├── RegistrationCompletedEvent.php ├── ShopActivatedEvent.php ├── ShopDeactivatedEvent.php └── ShopDeletedEvent.php ├── Exception ├── MalformedWebhookBodyException.php ├── MissingClaimException.php ├── MissingShopParameterException.php ├── ShopNotFoundException.php ├── SignatureInvalidException.php └── SignatureNotFoundException.php ├── Framework └── Collection.php ├── Gateway ├── Checkout │ ├── CheckoutGatewayCommand.php │ └── Command │ │ ├── AddCartErrorCommand.php │ │ ├── AddPaymentMethodCommand.php │ │ ├── AddPaymentMethodExtensionCommand.php │ │ ├── AddShippingMethodCommand.php │ │ ├── AddShippingMethodExtensionCommand.php │ │ ├── RemovePaymentMethodCommand.php │ │ └── RemoveShippingMethodCommand.php └── Context │ ├── Command │ ├── AddCustomerMessageCommand.php │ ├── ChangeBillingAddressCommand.php │ ├── ChangeCurrencyCommand.php │ ├── ChangeLanguageCommand.php │ ├── ChangePaymentMethodCommand.php │ ├── ChangeShippingAddressCommand.php │ ├── ChangeShippingLocationCommand.php │ ├── ChangeShippingMethodCommand.php │ ├── LoginCustomerCommand.php │ └── RegisterCustomerCommand.php │ └── ContextGatewayCommand.php ├── HttpClient ├── AuthenticatedClient.php ├── ClientFactory.php ├── Exception │ └── AuthenticationFailedException.php ├── LoggerClient.php ├── NullCache.php └── SimpleHttpClient │ ├── SimpleHttpClient.php │ └── SimpleHttpClientResponse.php ├── Registration ├── RandomStringShopSecretGenerator.php ├── RegistrationService.php └── ShopSecretGeneratorInterface.php ├── Response ├── ActionButtonResponse.php ├── GatewayResponse.php ├── InAppPurchasesResponse.php ├── PaymentResponse.php └── RefundResponse.php ├── Shop ├── ShopInterface.php ├── ShopRepositoryInterface.php └── ShopResolver.php ├── TaxProvider ├── CalculatedTax.php └── TaxProviderResponseBuilder.php └── Test ├── JWKSHelper.php ├── MockClient.php ├── MockShop.php ├── MockShopRepository.php └── _fixtures ├── jwks.json └── jwks_private.pem /.gitattributes: -------------------------------------------------------------------------------- 1 | /.github export-ignore 2 | /tests export-ignore 3 | /docs export-ignore 4 | /composer.lock export-ignore 5 | /phpstan.neon.dist export-ignore 6 | /.php-cs-fixer.dist.php export-ignore 7 | /phpunit.xml export-ignore 8 | /examples export-ignore 9 | /devenv.nix export-ignore 10 | /devenv.lock export-ignore 11 | /devenv.yaml export-ignore 12 | /README.md export-ignore 13 | /LICENSE export-ignore 14 | /.gitignore export-ignore 15 | /.envrc export-ignore 16 | /infection.json5 export-ignore -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shopware/app-php-sdk", 3 | "type": "library", 4 | "description": "Shopware App SDK for PHP", 5 | "keywords": [ 6 | "shopware", 7 | "app-system" 8 | ], 9 | "homepage": "https://www.shopware.com", 10 | "license": "MIT", 11 | "support": { 12 | "issues": "https://issues.shopware.com", 13 | "forum": "https://forum.shopware.com", 14 | "wiki": "https://developer.shopware.com", 15 | "docs": "https://developer.shopware.com", 16 | "chat": "https://slack.shopware.com" 17 | }, 18 | "require": { 19 | "php": "^8.1", 20 | "lcobucci/clock": "^3", 21 | "lcobucci/jwt": "^4.0 || ^5.0", 22 | "phpseclib/phpseclib": "3.0.42", 23 | "php-http/discovery": "^1.17", 24 | "psr/clock-implementation": "*", 25 | "psr/event-dispatcher": "^1.0", 26 | "psr/http-client": "^1.0", 27 | "psr/http-client-implementation": "*", 28 | "psr/http-factory": "^1.0", 29 | "psr/http-factory-implementation": "*", 30 | "psr/http-message": "^1.0 || ^2.0", 31 | "psr/simple-cache": "^3.0", 32 | "strobotti/php-jwk": "^1.4" 33 | }, 34 | "require-dev": { 35 | "async-aws/dynamo-db": "~3.2", 36 | "friendsofphp/php-cs-fixer": "^3.16", 37 | "infection/infection": "^0.29", 38 | "nyholm/psr7": "^1.7.0", 39 | "nyholm/psr7-server": "^1.0", 40 | "php-http/curl-client": "^2.2", 41 | "phpstan/phpstan": "^1.10.14", 42 | "phpunit/phpunit": "^10.5", 43 | "symfony/http-client": ">=6.4.16", 44 | "symfony/polyfill-uuid": "^1.31" 45 | }, 46 | "suggest": { 47 | "async-aws/dynamo-db": "For using the DynamoDBRepository" 48 | }, 49 | "autoload": { 50 | "psr-4": { 51 | "Shopware\\App\\SDK\\": "src/" 52 | } 53 | }, 54 | "autoload-dev": { 55 | "psr-4": { 56 | "Shopware\\App\\SDK\\Tests\\": "tests/" 57 | } 58 | }, 59 | "config": { 60 | "sort-packages": true, 61 | "allow-plugins": { 62 | "php-http/discovery": false, 63 | "infection/extension-installer": true 64 | } 65 | }, 66 | "scripts": { 67 | "test": "phpunit", 68 | "check": [ 69 | "phpunit", 70 | "php-cs-fixer fix", 71 | "phpstan analyse" 72 | ], 73 | "bc-check": "vendor/bin/roave-backward-compatibility-check" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Adapter/DynamoDB/DynamoDBRepository.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | class DynamoDBRepository implements ShopRepositoryInterface 19 | { 20 | public function __construct(private readonly DynamoDbClient $client, private readonly string $tableName) 21 | { 22 | } 23 | 24 | public function createShopStruct(string $shopId, string $shopUrl, string $shopSecret): ShopInterface 25 | { 26 | return new DynamoDBShop($shopId, $shopUrl, $shopSecret); 27 | } 28 | 29 | public function createShop(ShopInterface $shop): void 30 | { 31 | $this->client->putItem(new PutItemInput([ 32 | 'TableName' => $this->tableName, 33 | 'Item' => [ 34 | 'id' => ['S' => $shop->getShopId()], 35 | 'active' => ['BOOL' => $shop->isShopActive() ? '1' : '0'], 36 | 'url' => ['S' => $shop->getShopUrl()], 37 | 'secret' => ['S' => $shop->getShopSecret()], 38 | 'clientId' => ['S' => (string) $shop->getShopClientId()], 39 | 'clientSecret' => ['S' => (string) $shop->getShopClientSecret()], 40 | ], 41 | ])); 42 | } 43 | 44 | public function getShopFromId(string $shopId): ShopInterface|null 45 | { 46 | $item = $this->client->getItem(new GetItemInput([ 47 | 'TableName' => $this->tableName, 48 | 'Key' => [ 49 | 'id' => ['S' => $shopId], 50 | ], 51 | ]))->getItem(); 52 | 53 | if (!$item) { 54 | return null; 55 | } 56 | 57 | $shopClientId = $item['clientId']->getS(); 58 | $shopClientSecret = $item['clientSecret']->getS(); 59 | 60 | if ($shopClientSecret === '') { 61 | $shopClientSecret = null; 62 | } 63 | 64 | if ($shopClientId === '') { 65 | $shopClientId = null; 66 | } 67 | 68 | $active = $item['active']->getBool(); 69 | 70 | if ($active === null) { 71 | $active = false; 72 | } 73 | 74 | return new DynamoDBShop( 75 | $item['id']->getS() ?? '', 76 | $item['url']->getS() ?? '', 77 | $item['secret']->getS() ?? '', 78 | $shopClientId, 79 | $shopClientSecret, 80 | $active, 81 | ); 82 | } 83 | 84 | public function updateShop(ShopInterface $shop): void 85 | { 86 | $this->client->updateItem(new UpdateItemInput([ 87 | 'TableName' => $this->tableName, 88 | 'Key' => [ 89 | 'id' => ['S' => $shop->getShopId()], 90 | ], 91 | 'UpdateExpression' => 'SET active = :active, #u = :url, secret = :secret, clientId = :clientId, clientSecret = :clientSecret', 92 | 'ExpressionAttributeNames' => [ 93 | '#u' => 'url', 94 | ], 95 | 'ExpressionAttributeValues' => [ 96 | ':active' => ['BOOL' => $shop->isShopActive() ? '1' : '0'], 97 | ':url' => ['S' => $shop->getShopUrl()], 98 | ':secret' => ['S' => $shop->getShopSecret()], 99 | ':clientId' => ['S' => (string) $shop->getShopClientId()], 100 | ':clientSecret' => ['S' => (string) $shop->getShopClientSecret()], 101 | ], 102 | ])); 103 | } 104 | 105 | public function deleteShop(string $shopId): void 106 | { 107 | $this->client->deleteItem(new DeleteItemInput([ 108 | 'TableName' => $this->tableName, 109 | 'Key' => [ 110 | 'id' => ['S' => $shopId], 111 | ], 112 | ])); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/Adapter/DynamoDB/DynamoDBShop.php: -------------------------------------------------------------------------------- 1 | active; 18 | } 19 | 20 | public function getShopId(): string 21 | { 22 | return $this->shopId; 23 | } 24 | 25 | public function getShopUrl(): string 26 | { 27 | return $this->shopUrl; 28 | } 29 | 30 | public function getShopSecret(): string 31 | { 32 | return $this->shopSecret; 33 | } 34 | 35 | public function getShopClientId(): ?string 36 | { 37 | return $this->shopClientId; 38 | } 39 | 40 | public function getShopClientSecret(): ?string 41 | { 42 | return $this->shopClientSecret; 43 | } 44 | 45 | public function setShopApiCredentials(string $clientId, string $clientSecret): ShopInterface 46 | { 47 | $this->shopClientId = $clientId; 48 | $this->shopClientSecret = $clientSecret; 49 | 50 | return $this; 51 | } 52 | 53 | public function setShopUrl(string $url): ShopInterface 54 | { 55 | $this->shopUrl = $url; 56 | 57 | return $this; 58 | } 59 | 60 | public function setShopActive(bool $active): ShopInterface 61 | { 62 | $this->active = $active; 63 | 64 | return $this; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/AppConfiguration.php: -------------------------------------------------------------------------------- 1 | appName; 19 | } 20 | 21 | public function getAppSecret(): string 22 | { 23 | return $this->appSecret; 24 | } 25 | 26 | public function getRegistrationConfirmUrl(): string 27 | { 28 | return $this->registrationConfirmationUrl; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/AppLifecycle.php: -------------------------------------------------------------------------------- 1 | $shopRepository 32 | */ 33 | public function __construct( 34 | private readonly RegistrationService $registrationService, 35 | private readonly ShopResolver $shopResolver, 36 | private readonly ShopRepositoryInterface $shopRepository, 37 | private readonly LoggerInterface $logger = new NullLogger(), 38 | private readonly ?EventDispatcherInterface $eventDispatcher = null, 39 | ) { 40 | } 41 | 42 | public function register(RequestInterface $request): ResponseInterface 43 | { 44 | return $this->registrationService->register($request); 45 | } 46 | 47 | public function registerConfirm(RequestInterface $request): ResponseInterface 48 | { 49 | return $this->registrationService->registerConfirm($request); 50 | } 51 | 52 | public function activate(RequestInterface $request): ResponseInterface 53 | { 54 | return $this->handleShopStatus($request, true); 55 | } 56 | 57 | public function deactivate(RequestInterface $request): ResponseInterface 58 | { 59 | return $this->handleShopStatus($request, false); 60 | } 61 | 62 | /** 63 | * Handles the 'app.deleted' Hook to remove the shop from the repository 64 | */ 65 | public function delete(RequestInterface $request): ResponseInterface 66 | { 67 | $psrFactory = new Psr17Factory(); 68 | $response = $psrFactory->createResponse(204); 69 | 70 | $shop = $this->findShop($request); 71 | 72 | if ($shop === null) { 73 | return $response; 74 | } 75 | 76 | $this->eventDispatcher?->dispatch(new BeforeShopDeletionEvent($request, $shop)); 77 | 78 | $this->shopRepository->deleteShop($shop->getShopId()); 79 | 80 | $this->eventDispatcher?->dispatch(new ShopDeletedEvent($request, $shop)); 81 | 82 | $this->logger->info('Shop uninstalled', [ 83 | 'shop-id' => $shop->getShopId(), 84 | 'shop-url' => $shop->getShopUrl(), 85 | ]); 86 | 87 | return $response; 88 | } 89 | 90 | private function findShop(RequestInterface $request): ?ShopInterface 91 | { 92 | try { 93 | return $this->shopResolver->resolveShop($request); 94 | } catch (ShopNotFoundException) { 95 | return null; 96 | } 97 | } 98 | 99 | private function handleShopStatus(RequestInterface $request, bool $status): ResponseInterface 100 | { 101 | $psrFactory = new Psr17Factory(); 102 | $response = $psrFactory->createResponse(204); 103 | 104 | $shop = $this->findShop($request); 105 | 106 | if ($shop === null) { 107 | return $response; 108 | } 109 | 110 | if ($status) { 111 | $this->eventDispatcher?->dispatch(new BeforeShopActivateEvent($request, $shop)); 112 | } else { 113 | $this->eventDispatcher?->dispatch(new BeforeShopDeactivatedEvent($request, $shop)); 114 | } 115 | 116 | $this->shopRepository->updateShop($shop->setShopActive($status)); 117 | 118 | if ($status) { 119 | $this->eventDispatcher?->dispatch(new ShopActivatedEvent($request, $shop)); 120 | } else { 121 | $this->eventDispatcher?->dispatch(new ShopDeactivatedEvent($request, $shop)); 122 | } 123 | 124 | $this->logger->info($status ? 'Shop activated' : 'Shop deactivated', [ 125 | 'shop-id' => $shop->getShopId(), 126 | 'shop-url' => $shop->getShopUrl(), 127 | ]); 128 | 129 | return $response; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/Authentication/RequestVerifier.php: -------------------------------------------------------------------------------- 1 | getSignatureFromHeader($request, self::SHOPWARE_APP_SIGNATURE_HEADER); 39 | 40 | parse_str($request->getUri()->getQuery(), $queries); 41 | 42 | if (!isset($queries['shop-id'], $queries['shop-url'], $queries['timestamp'])) { 43 | throw new SignatureNotFoundException($request); 44 | } 45 | 46 | /** @var array{shop-id: string, shop-url: string, timestamp: string} $check */ 47 | $check = $queries; 48 | 49 | $this->verifySignature( 50 | $request, 51 | $appConfiguration->getAppSecret(), 52 | $this->buildValidationQuery($check), 53 | $signature 54 | ); 55 | } 56 | 57 | /** 58 | * @throws SignatureInvalidException 59 | * @throws SignatureNotFoundException 60 | */ 61 | public function authenticatePostRequest(RequestInterface $request, ShopInterface $shop): void 62 | { 63 | $signature = $this->getSignatureFromHeader($request, self::SHOPWARE_SHOP_SIGNATURE_HEADER); 64 | 65 | $content = $request->getBody()->getContents(); 66 | $request->getBody()->rewind(); 67 | $this->verifySignature( 68 | $request, 69 | $shop->getShopSecret(), 70 | $content, 71 | $signature 72 | ); 73 | } 74 | 75 | /** 76 | * @throws SignatureInvalidException 77 | * @throws SignatureNotFoundException 78 | */ 79 | public function authenticateGetRequest(RequestInterface $request, ShopInterface $shop): void 80 | { 81 | $signature = $this->getSignatureFromQuery($request); 82 | 83 | $this->verifySignature( 84 | $request, 85 | $shop->getShopSecret(), 86 | $this->removeSignatureFromQuery($request->getUri()->getQuery(), $signature), 87 | $signature 88 | ); 89 | } 90 | 91 | public function authenticateStorefrontRequest(RequestInterface $request, ShopInterface $shop): void 92 | { 93 | $token = $request->getHeaderLine('shopware-app-token'); 94 | 95 | if ($token === '') { 96 | throw new SignatureNotFoundException($request); 97 | } 98 | 99 | if ($shop->getShopSecret() === '' || $shop->getShopId() === '') { 100 | throw new ShopNotFoundException($shop->getShopId()); 101 | } 102 | 103 | $key = InMemory::plainText($shop->getShopSecret()); 104 | 105 | (new JwtFacade())->parse( 106 | $token, 107 | new SignedWith(new Sha256(), $key), 108 | new StrictValidAt($this->clock), 109 | new IssuedBy($shop->getShopId()) 110 | ); 111 | } 112 | 113 | /** 114 | * @throws SignatureNotFoundException 115 | */ 116 | private function getSignatureFromQuery(RequestInterface $request): string 117 | { 118 | \parse_str($request->getUri()->getQuery(), $queries); 119 | 120 | if (!isset($queries[self::SHOPWARE_SHOP_SIGNATURE_HEADER])) { 121 | throw new SignatureNotFoundException($request); 122 | } 123 | 124 | /** @var string $header */ 125 | $header = $queries[self::SHOPWARE_SHOP_SIGNATURE_HEADER]; 126 | 127 | return $header; 128 | } 129 | 130 | /** 131 | * @throws SignatureNotFoundException 132 | */ 133 | private function getSignatureFromHeader(RequestInterface $request, string $headerName): string 134 | { 135 | $signatureHeader = $request->getHeader($headerName); 136 | 137 | if (empty($signatureHeader)) { 138 | throw new SignatureNotFoundException($request); 139 | } 140 | 141 | return $signatureHeader[0]; 142 | } 143 | 144 | /** 145 | * @throws SignatureInvalidException 146 | */ 147 | private function verifySignature( 148 | RequestInterface $request, 149 | #[\SensitiveParameter] 150 | string $secret, 151 | string $message, 152 | string $signature 153 | ): void { 154 | $hmac = hash_hmac('sha256', $message, $secret); 155 | 156 | if (!hash_equals($hmac, $signature)) { 157 | throw new SignatureInvalidException($request); 158 | } 159 | } 160 | 161 | /** 162 | * @param array{shop-id: string, shop-url: string, timestamp: string} $queries 163 | */ 164 | private function buildValidationQuery(array $queries): string 165 | { 166 | return sprintf( 167 | 'shop-id=%s&shop-url=%s×tamp=%s', 168 | $queries['shop-id'], 169 | $queries['shop-url'], 170 | $queries['timestamp'] 171 | ); 172 | } 173 | 174 | private function removeSignatureFromQuery(string $query, string $signature): string 175 | { 176 | /** @var string $query */ 177 | $query = \preg_replace( 178 | sprintf('/&%s=%s/', self::SHOPWARE_SHOP_SIGNATURE_HEADER, $signature), 179 | '', 180 | $query 181 | ); 182 | 183 | return $query; 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /src/Authentication/ResponseSigner.php: -------------------------------------------------------------------------------- 1 | sign($shop->getShopId() . $shop->getShopUrl() . $appConfiguration->getAppName(), $appConfiguration->getAppSecret()); 16 | } 17 | 18 | public function signResponse(ResponseInterface $response, ShopInterface $shop): ResponseInterface 19 | { 20 | $content = $response->getBody()->getContents(); 21 | $response->getBody()->rewind(); 22 | 23 | return $response->withHeader( 24 | 'shopware-app-signature', 25 | $this->sign($content, $shop->getShopSecret()) 26 | ); 27 | } 28 | 29 | private function sign(string $message, #[\SensitiveParameter] string $secret): string 30 | { 31 | return hash_hmac('sha256', $message, $secret); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Context/ActionButton/ActionButtonAction.php: -------------------------------------------------------------------------------- 1 | $ids 17 | */ 18 | public function __construct( 19 | public readonly ShopInterface $shop, 20 | public readonly ActionSource $source, 21 | public readonly array $ids, 22 | public readonly string $entity, 23 | public readonly string $action, 24 | ) { 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Context/ActionSource.php: -------------------------------------------------------------------------------- 1 | $inAppPurchases The active in-app-purchases 16 | */ 17 | public function __construct( 18 | public readonly string $url, 19 | public readonly string $appVersion, 20 | public readonly Collection $inAppPurchases, 21 | ) { 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Context/ArrayStruct.php: -------------------------------------------------------------------------------- 1 | $data 11 | */ 12 | public function __construct(protected readonly array $data) 13 | { 14 | } 15 | 16 | /** 17 | * @return array 18 | */ 19 | public function toArray(): array 20 | { 21 | return $this->data; 22 | } 23 | 24 | /** 25 | * @return array 26 | */ 27 | public function jsonSerialize(): array 28 | { 29 | return $this->toArray(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Context/Cart/CalculatedPrice.php: -------------------------------------------------------------------------------- 1 | data['unitPrice']) || is_int($this->data['unitPrice'])); 15 | return $this->data['unitPrice']; 16 | } 17 | 18 | public function getTotalPrice(): float 19 | { 20 | \assert(is_float($this->data['totalPrice']) || is_int($this->data['totalPrice'])); 21 | return $this->data['totalPrice']; 22 | } 23 | 24 | public function getQuantity(): int 25 | { 26 | \assert(is_int($this->data['quantity'])); 27 | return $this->data['quantity']; 28 | } 29 | 30 | /** 31 | * @return Collection 32 | */ 33 | public function getCalculatedTaxes(): Collection 34 | { 35 | \assert(is_array($this->data['calculatedTaxes'])); 36 | 37 | return new Collection( 38 | \array_map( 39 | static fn (array $tax) => new CalculatedTax($tax), 40 | $this->data['calculatedTaxes'] 41 | ) 42 | ); 43 | } 44 | 45 | /** 46 | * @return Collection 47 | */ 48 | public function getTaxRules(): Collection 49 | { 50 | \assert(is_array($this->data['taxRules'])); 51 | 52 | return new Collection( 53 | \array_map( 54 | static fn (array $rule) => new TaxRule($rule), 55 | $this->data['taxRules'] 56 | ) 57 | ); 58 | } 59 | 60 | /** 61 | * @param Collection $prices 62 | */ 63 | public static function sum(Collection $prices): CalculatedPrice 64 | { 65 | /** @var array> $allTaxes */ 66 | $allTaxes = $prices->map(static fn (CalculatedPrice $price) => $price->getCalculatedTaxes()->all()); 67 | 68 | $taxSum = CalculatedTax::sum(new Collection(array_merge(...$allTaxes))); 69 | 70 | $rules = []; 71 | 72 | foreach ($prices as $price) { 73 | $rules = array_merge($rules, $price->getTaxRules()->jsonSerialize()); 74 | } 75 | 76 | return new CalculatedPrice([ 77 | 'unitPrice' => \array_sum($prices->map(static fn (CalculatedPrice $price): float => $price->getUnitPrice())), 78 | 'totalPrice' => \array_sum($prices->map(static fn (CalculatedPrice $price): float => $price->getTotalPrice())), 79 | 'quantity' => 1, 80 | 'calculatedTaxes' => $taxSum->map(static fn (CalculatedTax $tax) => $tax->toArray()), 81 | 'taxRules' => $rules, 82 | ]); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Context/Cart/CalculatedTax.php: -------------------------------------------------------------------------------- 1 | data['taxRate']) || is_int($this->data['taxRate'])); 15 | return $this->data['taxRate']; 16 | } 17 | 18 | public function getPrice(): float 19 | { 20 | \assert(is_float($this->data['price']) || is_int($this->data['price'])); 21 | return $this->data['price']; 22 | } 23 | 24 | public function getTax(): float 25 | { 26 | \assert(is_float($this->data['tax']) || is_int($this->data['tax'])); 27 | return $this->data['tax']; 28 | } 29 | 30 | public function getLabel(): ?string 31 | { 32 | \assert(is_string($this->data['label'] ?? null) || is_null($this->data['label'] ?? null)); 33 | return $this->data['label'] ?? null; 34 | } 35 | 36 | /** 37 | * @param Collection $calculatedTaxes 38 | * @return Collection 39 | */ 40 | public static function sum(Collection $calculatedTaxes): Collection 41 | { 42 | $new = []; 43 | 44 | foreach ($calculatedTaxes as $calculatedTax) { 45 | $exists = isset($new[$calculatedTax->getTaxRate()]); 46 | if (!$exists) { 47 | $new[$calculatedTax->getTaxRate()] = $calculatedTax; 48 | 49 | continue; 50 | } 51 | 52 | $new[$calculatedTax->getTaxRate()] = new CalculatedTax([ 53 | 'taxRate' => $calculatedTax->getTaxRate(), 54 | 'price' => $new[$calculatedTax->getTaxRate()]->getPrice() + $calculatedTax->getPrice(), 55 | 'tax' => $new[$calculatedTax->getTaxRate()]->getTax() + $calculatedTax->getTax(), 56 | 'label' => implode(' + ', array_filter([$new[$calculatedTax->getTaxRate()]->getLabel(), $calculatedTax->getLabel()])) ?: null, 57 | ]); 58 | } 59 | 60 | return new Collection($new); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Context/Cart/Cart.php: -------------------------------------------------------------------------------- 1 | data['token'])); 15 | return $this->data['token']; 16 | } 17 | 18 | public function getCustomerComment(): ?string 19 | { 20 | \assert(is_string($this->data['customerComment']) || is_null($this->data['customerComment'])); 21 | return $this->data['customerComment']; 22 | } 23 | 24 | public function getAffiliateCode(): ?string 25 | { 26 | \assert(is_string($this->data['affiliateCode']) || is_null($this->data['affiliateCode'])); 27 | return $this->data['affiliateCode']; 28 | } 29 | 30 | public function getCampaignCode(): ?string 31 | { 32 | \assert(is_string($this->data['campaignCode']) || is_null($this->data['campaignCode'])); 33 | return $this->data['campaignCode']; 34 | } 35 | 36 | /** 37 | * @return Collection 38 | */ 39 | public function getLineItems(): Collection 40 | { 41 | \assert(\is_array($this->data['lineItems'])); 42 | 43 | return new Collection(\array_map( 44 | static fn (array $lineItem) => new LineItem($lineItem), 45 | $this->data['lineItems'] 46 | )); 47 | } 48 | 49 | /** 50 | * @return Collection 51 | */ 52 | public function getDeliveries(): Collection 53 | { 54 | \assert(\is_array($this->data['deliveries'])); 55 | 56 | return new Collection(\array_map( 57 | static fn (array $delivery) => new Delivery($delivery), 58 | $this->data['deliveries'] 59 | )); 60 | } 61 | 62 | /** 63 | * @return Collection 64 | */ 65 | public function getTransactions(): Collection 66 | { 67 | \assert(\is_array($this->data['transactions'])); 68 | 69 | return new Collection(\array_map( 70 | static fn (array $transaction) => new CartTransaction($transaction), 71 | $this->data['transactions'] 72 | )); 73 | } 74 | 75 | public function getPrice(): CartPrice 76 | { 77 | \assert(is_array($this->data['price'])); 78 | return new CartPrice($this->data['price']); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Context/Cart/CartPrice.php: -------------------------------------------------------------------------------- 1 | data['netPrice'])); 19 | return $this->data['netPrice']; 20 | } 21 | 22 | public function getTotalPrice(): float 23 | { 24 | \assert(is_float($this->data['totalPrice'])); 25 | return $this->data['totalPrice']; 26 | } 27 | 28 | /** 29 | * @return Collection 30 | */ 31 | public function getCalculatedTaxes(): Collection 32 | { 33 | \assert(is_array($this->data['calculatedTaxes'])); 34 | 35 | return new Collection(\array_map(static function (array $calculatedTax): CalculatedTax { 36 | return new CalculatedTax($calculatedTax); 37 | }, $this->data['calculatedTaxes'])); 38 | } 39 | 40 | public function getTaxStatus(): string 41 | { 42 | \assert(is_string($this->data['taxStatus'])); 43 | return $this->data['taxStatus']; 44 | } 45 | 46 | /** 47 | * @return Collection 48 | */ 49 | public function getTaxRules(): Collection 50 | { 51 | \assert(\is_array($this->data['taxRules'])); 52 | 53 | return new Collection(\array_map(static function (array $taxRule): TaxRule { 54 | return new TaxRule($taxRule); 55 | }, $this->data['taxRules'])); 56 | } 57 | 58 | public function getPositionPrice(): float 59 | { 60 | \assert(is_float($this->data['positionPrice'])); 61 | return $this->data['positionPrice']; 62 | } 63 | 64 | public function getRawTotal(): float 65 | { 66 | \assert(is_float($this->data['rawTotal'])); 67 | return $this->data['rawTotal']; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Context/Cart/CartTransaction.php: -------------------------------------------------------------------------------- 1 | data['paymentMethodId'])); 14 | return $this->data['paymentMethodId']; 15 | } 16 | 17 | public function getAmount(): CalculatedPrice 18 | { 19 | \assert(is_array($this->data['amount'])); 20 | return new CalculatedPrice($this->data['amount']); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Context/Cart/Delivery.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | public function getPositions(): Collection 18 | { 19 | \assert(is_array($this->data['positions'])); 20 | 21 | return new Collection(\array_map(static function (array $position) { 22 | return new DeliveryPosition($position); 23 | }, $this->data['positions'])); 24 | } 25 | 26 | public function getLocation(): ShippingLocation 27 | { 28 | \assert(is_array($this->data['location'])); 29 | return new ShippingLocation($this->data['location']); 30 | } 31 | 32 | public function getShippingMethod(): ShippingMethod 33 | { 34 | \assert(is_array($this->data['shippingMethod'])); 35 | return new ShippingMethod($this->data['shippingMethod']); 36 | } 37 | 38 | public function getDeliveryDate(): DeliveryDate 39 | { 40 | \assert(is_array($this->data['deliveryDate'])); 41 | return new DeliveryDate($this->data['deliveryDate']); 42 | } 43 | 44 | public function getShippingCosts(): CalculatedPrice 45 | { 46 | \assert(is_array($this->data['shippingCosts'])); 47 | return new CalculatedPrice($this->data['shippingCosts']); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Context/Cart/DeliveryDate.php: -------------------------------------------------------------------------------- 1 | data['earliest'])); 14 | return new \DateTimeImmutable($this->data['earliest']); 15 | } 16 | 17 | public function getLatest(): \DateTimeInterface 18 | { 19 | \assert(is_string($this->data['latest'])); 20 | return new \DateTimeImmutable($this->data['latest']); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Context/Cart/DeliveryPosition.php: -------------------------------------------------------------------------------- 1 | data['identifier'])); 14 | return $this->data['identifier']; 15 | } 16 | 17 | public function getLineItem(): LineItem 18 | { 19 | \assert(is_array($this->data['lineItem'])); 20 | return new LineItem($this->data['lineItem']); 21 | } 22 | 23 | public function getQuantity(): int 24 | { 25 | \assert(is_int($this->data['quantity'])); 26 | return $this->data['quantity']; 27 | } 28 | 29 | public function getDeliveryDate(): DeliveryDate 30 | { 31 | \assert(is_array($this->data['deliveryDate'])); 32 | return new DeliveryDate($this->data['deliveryDate']); 33 | } 34 | 35 | public function getPrice(): CalculatedPrice 36 | { 37 | \assert(is_array($this->data['price'])); 38 | return new CalculatedPrice($this->data['price']); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Context/Cart/Error.php: -------------------------------------------------------------------------------- 1 | data['id'])); 15 | return $this->data['id']; 16 | } 17 | 18 | public function getUniqueIdentifier(): string 19 | { 20 | \assert(is_string($this->data['uniqueIdentifier'])); 21 | return $this->data['uniqueIdentifier']; 22 | } 23 | 24 | public function getType(): string 25 | { 26 | \assert(is_string($this->data['type'])); 27 | return $this->data['type']; 28 | } 29 | 30 | public function getReferencedId(): string 31 | { 32 | \assert(is_string($this->data['referencedId'])); 33 | return $this->data['referencedId']; 34 | } 35 | 36 | public function getLabel(): string 37 | { 38 | \assert(is_string($this->data['label'])); 39 | return $this->data['label']; 40 | } 41 | 42 | public function getDescription(): ?string 43 | { 44 | \assert(is_string($this->data['description']) || is_null($this->data['description'])); 45 | return $this->data['description']; 46 | } 47 | 48 | public function isGood(): bool 49 | { 50 | \assert(is_bool($this->data['good'])); 51 | return $this->data['good']; 52 | } 53 | 54 | public function getQuantity(): int 55 | { 56 | \assert(is_int($this->data['quantity'])); 57 | return $this->data['quantity']; 58 | } 59 | 60 | /** 61 | * @return array 62 | */ 63 | public function getPayload(): array 64 | { 65 | \assert(is_array($this->data['payload'])); 66 | return $this->data['payload']; 67 | } 68 | 69 | public function getPrice(): CalculatedPrice 70 | { 71 | \assert(is_array($this->data['price'])); 72 | return new CalculatedPrice($this->data['price']); 73 | } 74 | 75 | /** 76 | * @return array 77 | */ 78 | public function getStates(): array 79 | { 80 | \assert(is_array($this->data['states'])); 81 | 82 | return $this->data['states']; 83 | } 84 | 85 | /** 86 | * @return Collection 87 | */ 88 | public function getChildren(): Collection 89 | { 90 | \assert(is_array($this->data['children'])); 91 | 92 | return new Collection(\array_map(static fn (array $child): LineItem => new LineItem($child), $this->data['children'])); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Context/Cart/TaxRule.php: -------------------------------------------------------------------------------- 1 | data['taxRate']) || is_int($this->data['taxRate'])); 14 | return $this->data['taxRate']; 15 | } 16 | 17 | public function getPercentage(): float 18 | { 19 | \assert(is_float($this->data['percentage']) || is_int($this->data['percentage'])); 20 | return $this->data['percentage']; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Context/ContextResolver.php: -------------------------------------------------------------------------------- 1 | getBody()->getContents(), true, flags: \JSON_THROW_ON_ERROR); 51 | $request->getBody()->rewind(); 52 | 53 | if (!\is_array($body) || !isset($body['source']) || !\is_array($body['source'])) { 54 | throw new MalformedWebhookBodyException(); 55 | } 56 | 57 | return new WebhookAction( 58 | $shop, 59 | $this->parseSource($body['source'], $shop), 60 | $body['data']['event'], 61 | $body['data']['payload'], 62 | new DateTimeImmutable('@' . $body['timestamp']) 63 | ); 64 | } 65 | 66 | public function assembleActionButton(RequestInterface $request, ShopInterface $shop): ActionButtonAction 67 | { 68 | $body = \json_decode($request->getBody()->getContents(), true, flags: \JSON_THROW_ON_ERROR); 69 | $request->getBody()->rewind(); 70 | 71 | if (!\is_array($body) || !isset($body['source']) || !\is_array($body['source'])) { 72 | throw new MalformedWebhookBodyException(); 73 | } 74 | 75 | return new ActionButtonAction( 76 | $shop, 77 | $this->parseSource($body['source'], $shop), 78 | $body['data']['ids'], 79 | $body['data']['entity'], 80 | $body['data']['action'] 81 | ); 82 | } 83 | 84 | public function assembleModule(RequestInterface $request, ShopInterface $shop): ModuleAction 85 | { 86 | \parse_str($request->getUri()->getQuery(), $params); 87 | 88 | if (!isset($params['sw-version'], $params['sw-context-language'], $params['sw-user-language']) 89 | || !is_string($params['sw-version']) 90 | || !is_string($params['sw-context-language']) 91 | || !is_string($params['sw-user-language']) 92 | ) { 93 | throw new MalformedWebhookBodyException(); 94 | } 95 | 96 | if (!empty($params['in-app-purchases'])) { 97 | /** @var non-empty-string $inAppPurchaseString */ 98 | $inAppPurchaseString = $params['in-app-purchases']; 99 | $inAppPurchases = $this->inAppPurchaseProvider?->decodePurchases($inAppPurchaseString, $shop); 100 | } 101 | 102 | return new ModuleAction( 103 | $shop, 104 | $params['sw-version'], 105 | $params['sw-context-language'], 106 | $params['sw-user-language'], 107 | $inAppPurchases ?? new Collection(), 108 | ); 109 | } 110 | 111 | public function assembleTaxProvider(RequestInterface $request, ShopInterface $shop): TaxProviderAction 112 | { 113 | $body = \json_decode($request->getBody()->getContents(), true, flags: \JSON_THROW_ON_ERROR); 114 | $request->getBody()->rewind(); 115 | 116 | if (!\is_array($body) || !isset($body['source']) || !\is_array($body['source'])) { 117 | throw new MalformedWebhookBodyException(); 118 | } 119 | 120 | return new TaxProviderAction( 121 | $shop, 122 | $this->parseSource($body['source'], $shop), 123 | new SalesChannelContext($body['context']), 124 | new Cart($body['cart']) 125 | ); 126 | } 127 | 128 | public function assemblePaymentPay(RequestInterface $request, ShopInterface $shop): PaymentPayAction 129 | { 130 | $body = \json_decode($request->getBody()->getContents(), true, flags: \JSON_THROW_ON_ERROR); 131 | $request->getBody()->rewind(); 132 | 133 | if (!\is_array($body) || !isset($body['source']) || !\is_array($body['source'])) { 134 | throw new MalformedWebhookBodyException(); 135 | } 136 | 137 | return new PaymentPayAction( 138 | $shop, 139 | $this->parseSource($body['source'], $shop), 140 | new Order($body['order']), 141 | new OrderTransaction($body['orderTransaction']), 142 | $body['returnUrl'] ?? null, 143 | isset($body['recurring']) ? new RecurringData($body['recurring']) : null, 144 | $body['requestData'] ?? [] 145 | ); 146 | } 147 | 148 | public function assemblePaymentFinalize(RequestInterface $request, ShopInterface $shop): PaymentFinalizeAction 149 | { 150 | $body = \json_decode($request->getBody()->getContents(), true, flags: \JSON_THROW_ON_ERROR); 151 | $request->getBody()->rewind(); 152 | 153 | if (!\is_array($body) || !isset($body['source']) || !\is_array($body['source'])) { 154 | throw new MalformedWebhookBodyException(); 155 | } 156 | 157 | return new PaymentFinalizeAction( 158 | $shop, 159 | $this->parseSource($body['source'], $shop), 160 | new OrderTransaction($body['orderTransaction']), 161 | isset($body['recurring']) ? new RecurringData($body['recurring']) : null, 162 | $body['queryParameters'] ?? [] 163 | ); 164 | } 165 | 166 | public function assemblePaymentCapture(RequestInterface $request, ShopInterface $shop): PaymentCaptureAction 167 | { 168 | $body = \json_decode($request->getBody()->getContents(), true, flags: \JSON_THROW_ON_ERROR); 169 | $request->getBody()->rewind(); 170 | 171 | if (!\is_array($body) || !isset($body['source']) || !\is_array($body['source'])) { 172 | throw new MalformedWebhookBodyException(); 173 | } 174 | 175 | return new PaymentCaptureAction( 176 | $shop, 177 | $this->parseSource($body['source'], $shop), 178 | new Order($body['order']), 179 | new OrderTransaction($body['orderTransaction']), 180 | isset($body['recurring']) ? new RecurringData($body['recurring']) : null, 181 | $body['preOrderPayment'] ?? [] 182 | ); 183 | } 184 | 185 | public function assemblePaymentRecurringCapture(RequestInterface $request, ShopInterface $shop): PaymentRecurringAction 186 | { 187 | $body = \json_decode($request->getBody()->getContents(), true, flags: \JSON_THROW_ON_ERROR); 188 | $request->getBody()->rewind(); 189 | 190 | if (!\is_array($body) || !isset($body['source']) || !\is_array($body['source'])) { 191 | throw new MalformedWebhookBodyException(); 192 | } 193 | 194 | return new PaymentRecurringAction( 195 | $shop, 196 | $this->parseSource($body['source'], $shop), 197 | new Order($body['order']), 198 | new OrderTransaction($body['orderTransaction']), 199 | ); 200 | } 201 | 202 | public function assemblePaymentValidate(RequestInterface $request, ShopInterface $shop): PaymentValidateAction 203 | { 204 | $body = \json_decode($request->getBody()->getContents(), true, flags: \JSON_THROW_ON_ERROR); 205 | $request->getBody()->rewind(); 206 | 207 | if (!\is_array($body) || !isset($body['source']) || !\is_array($body['source'])) { 208 | throw new MalformedWebhookBodyException(); 209 | } 210 | 211 | return new PaymentValidateAction( 212 | $shop, 213 | $this->parseSource($body['source'], $shop), 214 | new Cart($body['cart']), 215 | new SalesChannelContext($body['salesChannelContext']), 216 | $body['requestData'] ?? [] 217 | ); 218 | } 219 | 220 | public function assemblePaymentRefund(RequestInterface $request, ShopInterface $shop): RefundAction 221 | { 222 | $body = \json_decode($request->getBody()->getContents(), true, flags: \JSON_THROW_ON_ERROR); 223 | $request->getBody()->rewind(); 224 | 225 | if (!\is_array($body) || !isset($body['source']) || !\is_array($body['source'])) { 226 | throw new MalformedWebhookBodyException(); 227 | } 228 | 229 | return new RefundAction( 230 | $shop, 231 | $this->parseSource($body['source'], $shop), 232 | new Order($body['order']), 233 | new Refund($body['refund']), 234 | ); 235 | } 236 | 237 | /** 238 | * @throws MalformedWebhookBodyException 239 | */ 240 | public function assembleStorefrontRequest(RequestInterface $request, ShopInterface $shop): StorefrontAction 241 | { 242 | $token = $request->getHeaderLine('shopware-app-token'); 243 | 244 | if (empty($token)) { 245 | /** @infection-ignore-all false friend */ 246 | throw new MalformedWebhookBodyException(); 247 | } 248 | 249 | $parts = explode('.', $token); 250 | 251 | if (count($parts) !== 3) { 252 | throw new MalformedWebhookBodyException(); 253 | } 254 | 255 | /** @var StorefrontClaimsArray $claims */ 256 | $claims = \json_decode(\base64_decode($parts[1]), true, flags: JSON_THROW_ON_ERROR); 257 | 258 | if (isset($claims['inAppPurchases'])) { 259 | /** @phpstan-ignore booleanNot.alwaysFalse(phpstan claims, that the InAppPurchases always are strings) */ 260 | if (!\is_string($claims['inAppPurchases']) || empty($claims['inAppPurchases'])) { 261 | throw new MalformedWebhookBodyException(); 262 | } 263 | 264 | $inAppPurchases = $this->inAppPurchaseProvider?->decodePurchases($claims['inAppPurchases'], $shop); 265 | } 266 | 267 | return new StorefrontAction( 268 | $shop, 269 | new StorefrontClaims($claims), 270 | $inAppPurchases ?? new Collection() 271 | ); 272 | } 273 | 274 | public function assembleCheckoutGatewayRequest(RequestInterface $request, ShopInterface $shop): CheckoutGatewayAction 275 | { 276 | $body = \json_decode($request->getBody()->getContents(), true, flags: \JSON_THROW_ON_ERROR); 277 | $request->getBody()->rewind(); 278 | 279 | if (!\is_array($body) || !isset($body['source']) || !\is_array($body['source'])) { 280 | throw new MalformedWebhookBodyException(); 281 | } 282 | 283 | return new CheckoutGatewayAction( 284 | $shop, 285 | $this->parseSource($body['source'], $shop), 286 | new Cart($body['cart']), 287 | new SalesChannelContext($body['salesChannelContext']), 288 | new Collection(\array_flip($body['paymentMethods'])), 289 | new Collection(\array_flip($body['shippingMethods'])), 290 | ); 291 | } 292 | 293 | public function assembleContextGatewayRequest(RequestInterface $request, ShopInterface $shop): ContextGatewayAction 294 | { 295 | $body = \json_decode($request->getBody()->getContents(), true, flags: \JSON_THROW_ON_ERROR); 296 | $request->getBody()->rewind(); 297 | 298 | if (!\is_array($body) || !isset($body['source']) || !\is_array($body['source']) || !isset($body['data']) || !\is_array($body['data'])) { 299 | throw new MalformedWebhookBodyException(); 300 | } 301 | 302 | return new ContextGatewayAction( 303 | $shop, 304 | $this->parseSource($body['source'], $shop), 305 | new Cart($body['cart']), 306 | new SalesChannelContext($body['salesChannelContext']), 307 | $body['data'], 308 | ); 309 | } 310 | 311 | public function assembleInAppPurchasesFilterRequest(RequestInterface $request, ShopInterface $shop): FilterAction 312 | { 313 | $body = \json_decode($request->getBody()->getContents(), true, flags: \JSON_THROW_ON_ERROR); 314 | $request->getBody()->rewind(); 315 | 316 | if (!\is_array($body) || !isset($body['source']) || !\is_array($body['source'])) { 317 | throw new MalformedWebhookBodyException(); 318 | } 319 | 320 | if (!isset($body['purchases']) || !\is_array($body['purchases'])) { 321 | throw new MalformedWebhookBodyException(); 322 | } 323 | 324 | return new FilterAction( 325 | $shop, 326 | $this->parseSource($body['source'], $shop), 327 | new Collection($body['purchases']) 328 | ); 329 | } 330 | 331 | /** 332 | * @param array $source 333 | */ 334 | private function parseSource(array $source, ShopInterface $shop): ActionSource 335 | { 336 | if (!isset($source['url'], $source['appVersion']) || !\is_string($source['url']) || !\is_string($source['appVersion'])) { 337 | throw new MalformedWebhookBodyException(); 338 | } 339 | 340 | if (isset($source['inAppPurchases'])) { 341 | if (!\is_string($source['inAppPurchases']) || empty($source['inAppPurchases'])) { 342 | throw new MalformedWebhookBodyException(); 343 | } 344 | 345 | $inAppPurchases = $this->inAppPurchaseProvider?->decodePurchases($source['inAppPurchases'], $shop); 346 | } 347 | 348 | 349 | return new ActionSource( 350 | $source['url'], 351 | $source['appVersion'], 352 | $inAppPurchases ?? new Collection(), 353 | ); 354 | } 355 | } 356 | -------------------------------------------------------------------------------- /src/Context/Gateway/Checkout/CheckoutGatewayAction.php: -------------------------------------------------------------------------------- 1 | $paymentMethods 17 | * @param Collection $shippingMethods 18 | */ 19 | public function __construct( 20 | public readonly ShopInterface $shop, 21 | public readonly ActionSource $source, 22 | public readonly Cart $cart, 23 | public readonly SalesChannelContext $context, 24 | public readonly Collection $paymentMethods, 25 | public readonly Collection $shippingMethods, 26 | ) { 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Context/Gateway/Context/ContextGatewayAction.php: -------------------------------------------------------------------------------- 1 | $data - Additional data to be passed to the action. 16 | */ 17 | public function __construct( 18 | public readonly ShopInterface $shop, 19 | public readonly ActionSource $source, 20 | public readonly Cart $cart, 21 | public readonly SalesChannelContext $context, 22 | public readonly array $data = [], 23 | ) { 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Context/Gateway/InAppFeatures/FilterAction.php: -------------------------------------------------------------------------------- 1 | $purchases - The list of purchases to filter for 18 | */ 19 | public function __construct( 20 | public readonly ShopInterface $shop, 21 | public readonly ActionSource $source, 22 | public readonly Collection $purchases, 23 | ) { 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Context/InAppPurchase/HasMatchingDomain.php: -------------------------------------------------------------------------------- 1 | claims()->all() as $inAppPurchase) { 32 | if (!\array_key_exists('sub', $inAppPurchase)) { 33 | throw ConstraintViolation::error('Missing sub claim', $this); 34 | } 35 | 36 | $host = \parse_url($this->shop->getShopUrl(), \PHP_URL_HOST); 37 | 38 | if ($inAppPurchase['sub'] !== $host) { 39 | throw ConstraintViolation::error('Token domain invalid: ' . $inAppPurchase['sub'] . ', expected: ' . $host, $this); 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Context/InAppPurchase/HasValidRSAJWKSignature.php: -------------------------------------------------------------------------------- 1 | validateAlgorithm($token); 35 | 36 | $key = $this->getValidKey($token); 37 | 38 | /** @var non-empty-string $pem */ 39 | $pem = $this->convertToPem($key); 40 | 41 | /** @var string $alg */ 42 | $alg = $token->headers()->get('alg'); 43 | 44 | $signer = $this->getSigner($alg); 45 | 46 | (new SignedWith($signer, InMemory::plainText($pem)))->assert($token); 47 | } 48 | 49 | private function validateAlgorithm(Token $token): void 50 | { 51 | /** @var string $alg */ 52 | $alg = $token->headers()->get('alg'); 53 | 54 | if (!\in_array($alg, self::ALGORITHMS, true)) { 55 | throw new InvalidKeyProvided(\sprintf('Invalid algorithm (alg) in JWT header: "%s"', $alg)); 56 | } 57 | } 58 | 59 | private function getValidKey(Token $token): RsaKey 60 | { 61 | /** @var string $kid */ 62 | $kid = $token->headers()->get('kid'); 63 | 64 | if (!$kid) { 65 | throw new InvalidKeyProvided('Key ID (kid) missing from JWT header'); 66 | } 67 | 68 | /** @var RsaKey|null $key */ 69 | $key = $this->keys->getKeyById($kid); 70 | 71 | return $key ?? throw new InvalidKeyProvided(\sprintf('Key with ID (kid) "%s" not found in JWKS', $kid)); 72 | } 73 | 74 | private function convertToPem(KeyInterface $key): string 75 | { 76 | return (new KeyConverter())->keyToPem($key); 77 | } 78 | 79 | private function getSigner(string $alg): Rsa 80 | { 81 | return match ($alg) { 82 | default => new Sha256(), 83 | 'RS384' => new Sha384(), 84 | 'RS512' => new Sha512(), 85 | }; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Context/InAppPurchase/InAppPurchase.php: -------------------------------------------------------------------------------- 1 | 30 | */ 31 | public function decodePurchases(string $encodedPurchases, ShopInterface $shop, bool $retried = false): Collection 32 | { 33 | try { 34 | $keys = $this->keyFetcher->getKey($retried); 35 | $signatureValidator = new HasValidRSAJWKSignature($keys); 36 | 37 | $parser = new Parser(new JoseEncoder()); 38 | /** @var Token\Plain $token */ 39 | $token = $parser->parse($encodedPurchases); 40 | 41 | $validator = new Validator(); 42 | $validator->assert($token, $signatureValidator); 43 | 44 | return $this->transformClaims($token); 45 | } catch (\Exception $e) { 46 | if (!$retried) { 47 | return $this->decodePurchases($encodedPurchases, $shop, true); 48 | } 49 | 50 | $this->logger->error('Failed to decode in-app purchases: ' . $e->getMessage()); 51 | 52 | return new Collection(); 53 | } 54 | } 55 | 56 | /** 57 | * @return Collection 58 | * @throws \Exception 59 | */ 60 | private function transformClaims(Token\Plain $token): Collection 61 | { 62 | $inAppPurchases = new Collection(); 63 | 64 | /** @var InAppPurchaseArray $inAppPurchase */ 65 | foreach ($token->claims()->all() as $inAppPurchase) { 66 | if (!\array_key_exists('identifier', $inAppPurchase)) { 67 | continue; 68 | } 69 | 70 | $identifier = $inAppPurchase['identifier']; 71 | $nextBookingDate = isset($inAppPurchase['nextBookingDate']) ? new \DateTime($inAppPurchase['nextBookingDate']) : null; 72 | 73 | $inAppPurchaseObject = new InAppPurchase( 74 | $identifier, 75 | $inAppPurchase['quantity'], 76 | $nextBookingDate, 77 | ); 78 | 79 | $inAppPurchases->set($identifier, $inAppPurchaseObject); 80 | } 81 | 82 | return $inAppPurchases; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Context/InAppPurchase/SBPStoreKeyFetcher.php: -------------------------------------------------------------------------------- 1 | fetchAndStoreKey(); 39 | } 40 | 41 | return $this->getStoredKeys(); 42 | } 43 | 44 | private function getStoredKeys(): KeySet 45 | { 46 | /** @var string|null $storedKeys */ 47 | $storedKeys = $this->cache->get(self::SBP_JWT_CACHE_KEY); 48 | 49 | if (!$storedKeys) { 50 | return new KeySet(); 51 | } 52 | 53 | return (new KeySetFactory())->createFromJSON($storedKeys); 54 | } 55 | 56 | private function fetchAndStoreKey(): void 57 | { 58 | $request = new Request('GET', \sprintf('%s/inappfeatures/jwks', self::SBP_JWT_API_HOST)); 59 | 60 | try { 61 | $response = $this->client->sendRequest($request); 62 | 63 | if ($response->getStatusCode() !== 200) { 64 | throw new RequestException('Request to the SBP failed.', $request); 65 | } 66 | 67 | $result = $response->getBody()->getContents(); 68 | 69 | if (!$this->cache->set(self::SBP_JWT_CACHE_KEY, $result)) { 70 | throw new \Exception('The JWKS was not stored in the cache successfully.'); 71 | } 72 | } catch (\Throwable $e) { 73 | $this->logger->error('Unable to fetch a JWKS token from SBP: ' . $e->getMessage()); 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Context/Module/ModuleAction.php: -------------------------------------------------------------------------------- 1 | $inAppPurchases - The active in-app-purchases 17 | */ 18 | public function __construct( 19 | public readonly ShopInterface $shop, 20 | public readonly string $shopwareVersion, 21 | public readonly string $contentLanguage, 22 | public readonly string $userLanguage, 23 | public readonly Collection $inAppPurchases, 24 | ) { 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Context/Order/Order.php: -------------------------------------------------------------------------------- 1 | data['id'])); 23 | return $this->data['id']; 24 | } 25 | 26 | public function getOrderNumber(): string 27 | { 28 | \assert(\is_string($this->data['orderNumber'])); 29 | return $this->data['orderNumber']; 30 | } 31 | 32 | public function getCurrencyFactor(): float 33 | { 34 | \assert(\is_float($this->data['currencyFactor']) || is_int($this->data['currencyFactor'])); 35 | return $this->data['currencyFactor']; 36 | } 37 | 38 | public function getOrderDate(): \DateTimeInterface 39 | { 40 | \assert(is_string($this->data['orderDateTime'])); 41 | return new \DateTimeImmutable($this->data['orderDateTime']); 42 | } 43 | 44 | public function getPrice(): CartPrice 45 | { 46 | \assert(\is_array($this->data['price'])); 47 | return new CartPrice($this->data['price']); 48 | } 49 | 50 | public function getAmountTotal(): float 51 | { 52 | \assert(\is_float($this->data['amountTotal']) || is_int($this->data['amountTotal'])); 53 | return $this->data['amountTotal']; 54 | } 55 | 56 | public function getAmountNet(): float 57 | { 58 | \assert(\is_float($this->data['amountNet']) || is_int($this->data['amountNet'])); 59 | return $this->data['amountNet']; 60 | } 61 | 62 | public function getPositionPrice(): float 63 | { 64 | \assert(\is_float($this->data['positionPrice']) || is_int($this->data['positionPrice'])); 65 | return $this->data['positionPrice']; 66 | } 67 | 68 | public function getTaxStatus(): string 69 | { 70 | \assert(\is_string($this->data['taxStatus'])); 71 | return $this->data['taxStatus']; 72 | } 73 | 74 | public function getShippingTotal(): float 75 | { 76 | \assert(\is_float($this->data['shippingTotal']) || is_int($this->data['shippingTotal'])); 77 | return $this->data['shippingTotal']; 78 | } 79 | 80 | public function getShippingCosts(): CalculatedPrice 81 | { 82 | \assert(\is_array($this->data['shippingCosts'])); 83 | return new CalculatedPrice($this->data['shippingCosts']); 84 | } 85 | 86 | public function getOrderCustomer(): OrderCustomer 87 | { 88 | \assert(\is_array($this->data['orderCustomer'])); 89 | return new OrderCustomer($this->data['orderCustomer']); 90 | } 91 | 92 | public function getCurrency(): Currency 93 | { 94 | \assert(\is_array($this->data['currency'])); 95 | return new Currency($this->data['currency']); 96 | } 97 | 98 | public function getBillingAddress(): Address 99 | { 100 | \assert(\is_array($this->data['billingAddress'])); 101 | return new Address($this->data['billingAddress']); 102 | } 103 | 104 | /** 105 | * @return Collection 106 | */ 107 | public function getLineItems(): Collection 108 | { 109 | \assert(\is_array($this->data['lineItems'])); 110 | 111 | return new Collection(\array_map(static function (array $lineItem): OrderLineItem { 112 | return new OrderLineItem($lineItem); 113 | }, $this->data['lineItems'])); 114 | } 115 | 116 | public function getItemRounding(): RoundingConfig 117 | { 118 | \assert(\is_array($this->data['itemRounding'])); 119 | return new RoundingConfig($this->data['itemRounding']); 120 | } 121 | 122 | public function getTotalRounding(): RoundingConfig 123 | { 124 | \assert(\is_array($this->data['totalRounding'])); 125 | return new RoundingConfig($this->data['totalRounding']); 126 | } 127 | 128 | public function getDeepLinkCode(): string 129 | { 130 | \assert(\is_string($this->data['deepLinkCode'])); 131 | return $this->data['deepLinkCode']; 132 | } 133 | 134 | public function getSalesChannelId(): string 135 | { 136 | \assert(\is_string($this->data['salesChannelId'])); 137 | return $this->data['salesChannelId']; 138 | } 139 | 140 | /** 141 | * @return Collection 142 | */ 143 | public function getDeliveries(): Collection 144 | { 145 | \assert(\is_array($this->data['deliveries'])); 146 | 147 | return new Collection(\array_map(static function (array $delivery): OrderDelivery { 148 | return new OrderDelivery($delivery); 149 | }, $this->data['deliveries'])); 150 | } 151 | 152 | /** 153 | * @return Collection 154 | */ 155 | public function getTransactions(): Collection 156 | { 157 | \assert(\is_array($this->data['transactions'])); 158 | 159 | return new Collection(\array_map(static function (array $transaction): OrderTransaction { 160 | return new OrderTransaction($transaction); 161 | }, $this->data['transactions'])); 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/Context/Order/OrderCustomer.php: -------------------------------------------------------------------------------- 1 | data['id'])); 19 | return $this->data['id']; 20 | } 21 | 22 | public function getEmail(): string 23 | { 24 | \assert(is_string($this->data['email'])); 25 | return $this->data['email']; 26 | } 27 | 28 | public function getFirstName(): string 29 | { 30 | \assert(is_string($this->data['firstName'])); 31 | return $this->data['firstName']; 32 | } 33 | 34 | public function getLastName(): string 35 | { 36 | \assert(is_string($this->data['lastName'])); 37 | return $this->data['lastName']; 38 | } 39 | 40 | public function getTitle(): ?string 41 | { 42 | \assert(is_string($this->data['title']) || is_null($this->data['title'])); 43 | return $this->data['title']; 44 | } 45 | 46 | /** 47 | * @return string[] 48 | */ 49 | public function getVatIds(): array 50 | { 51 | \assert(is_array($this->data['vatIds'])); 52 | return $this->data['vatIds']; 53 | } 54 | 55 | public function getCompany(): ?string 56 | { 57 | \assert(is_string($this->data['company']) || is_null($this->data['company'])); 58 | return $this->data['company']; 59 | } 60 | 61 | public function getCustomerNumber(): string 62 | { 63 | \assert(is_string($this->data['customerNumber'])); 64 | return $this->data['customerNumber']; 65 | } 66 | 67 | public function getSalutation(): ?Salutation 68 | { 69 | \assert(is_array($this->data['salutation']) || is_null($this->data['salutation'])); 70 | 71 | if ($this->data['salutation'] === null) { 72 | return null; 73 | } 74 | 75 | return new Salutation($this->data['salutation']); 76 | } 77 | 78 | public function getRemoteAddress(): string 79 | { 80 | \assert(is_string($this->data['remoteAddress'])); 81 | return $this->data['remoteAddress']; 82 | } 83 | 84 | public function getCustomer(): Customer 85 | { 86 | \assert(\is_array($this->data['customer'])); 87 | return new Customer($this->data['customer']); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Context/Order/OrderDelivery.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | public function getTrackingCodes(): array 20 | { 21 | \assert(\is_array($this->data['trackingCodes'])); 22 | return $this->data['trackingCodes']; 23 | } 24 | 25 | public function getShippingCosts(): CalculatedPrice 26 | { 27 | \assert(\is_array($this->data['shippingCosts'])); 28 | return new CalculatedPrice($this->data['shippingCosts']); 29 | } 30 | 31 | public function getShippingOrderAddress(): Address 32 | { 33 | \assert(\is_array($this->data['shippingOrderAddress'])); 34 | return new Address($this->data['shippingOrderAddress']); 35 | } 36 | 37 | public function getStateMachineState(): StateMachineState 38 | { 39 | \assert(\is_array($this->data['stateMachineState'])); 40 | return new StateMachineState($this->data['stateMachineState']); 41 | } 42 | 43 | public function getShippingDateEarliest(): \DateTimeInterface 44 | { 45 | \assert(\is_string($this->data['shippingDateEarliest'])); 46 | return new \DateTimeImmutable($this->data['shippingDateEarliest']); 47 | } 48 | 49 | public function getShippingDateLatest(): \DateTimeInterface 50 | { 51 | \assert(\is_string($this->data['shippingDateLatest'])); 52 | return new \DateTimeImmutable($this->data['shippingDateLatest']); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Context/Order/OrderLineItem.php: -------------------------------------------------------------------------------- 1 | data['parentId']) || is_null($this->data['parentId'])); 14 | return $this->data['parentId']; 15 | } 16 | 17 | public function getPosition(): int 18 | { 19 | \assert(is_int($this->data['position'])); 20 | return $this->data['position']; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Context/Order/OrderTransaction.php: -------------------------------------------------------------------------------- 1 | data['id'])); 19 | return $this->data['id']; 20 | } 21 | 22 | public function getAmount(): CalculatedPrice 23 | { 24 | \assert(\is_array($this->data['amount'])); 25 | return new CalculatedPrice($this->data['amount']); 26 | } 27 | 28 | public function getPaymentMethod(): PaymentMethod 29 | { 30 | \assert(\is_array($this->data['paymentMethod'])); 31 | return new PaymentMethod($this->data['paymentMethod']); 32 | } 33 | 34 | public function getStateMachineState(): StateMachineState 35 | { 36 | \assert(\is_array($this->data['stateMachineState'])); 37 | return new StateMachineState($this->data['stateMachineState']); 38 | } 39 | 40 | public function getOrder(): Order 41 | { 42 | \assert(\is_array($this->data['order'])); 43 | return new Order($this->data['order']); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Context/Order/StateMachineState.php: -------------------------------------------------------------------------------- 1 | data['id'])); 14 | return $this->data['id']; 15 | } 16 | 17 | public function getTechnicalName(): string 18 | { 19 | \assert(\is_string($this->data['technicalName'])); 20 | return $this->data['technicalName']; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Context/Payment/PaymentCaptureAction.php: -------------------------------------------------------------------------------- 1 | $requestData - Contains the result of PaymentResponse::validateSuccessResponse 16 | */ 17 | public function __construct( 18 | public readonly ShopInterface $shop, 19 | public readonly ActionSource $source, 20 | public readonly Order $order, 21 | public readonly OrderTransaction $orderTransaction, 22 | public readonly ?RecurringData $recurring = null, 23 | public readonly array $requestData = [], 24 | ) { 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Context/Payment/PaymentFinalizeAction.php: -------------------------------------------------------------------------------- 1 | $queryParameters - Contains all query parameters passed to Shopware at the redirect of the payment provider 18 | */ 19 | public function __construct( 20 | public readonly ShopInterface $shop, 21 | public readonly ActionSource $source, 22 | public readonly OrderTransaction $orderTransaction, 23 | public readonly ?RecurringData $recurring = null, 24 | public readonly array $queryParameters = [], 25 | ) { 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Context/Payment/PaymentPayAction.php: -------------------------------------------------------------------------------- 1 | in the app manifest 14 | */ 15 | class PaymentPayAction 16 | { 17 | /** 18 | * @param string|null $returnUrl - Return url is only provided on async payments 19 | * @param array $requestData - Request data is only provided on async payments 20 | */ 21 | public function __construct( 22 | public readonly ShopInterface $shop, 23 | public readonly ActionSource $source, 24 | public readonly Order $order, 25 | public readonly OrderTransaction $orderTransaction, 26 | public readonly ?string $returnUrl = null, 27 | public readonly ?RecurringData $recurring = null, 28 | public readonly array $requestData = [], 29 | ) { 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Context/Payment/PaymentRecurringAction.php: -------------------------------------------------------------------------------- 1 | $requestData - Contains all parameters passed to the cart 20 | */ 21 | public function __construct( 22 | public readonly ShopInterface $shop, 23 | public readonly ActionSource $source, 24 | public readonly Cart $cart, 25 | public readonly SalesChannelContext $salesChannelContext, 26 | public readonly array $requestData 27 | ) { 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Context/Payment/RecurringData.php: -------------------------------------------------------------------------------- 1 | data['subscriptionId'])); 14 | return $this->data['subscriptionId']; 15 | } 16 | 17 | public function getNextSchedule(): \DateTimeInterface 18 | { 19 | \assert(\is_string($this->data['nextSchedule'])); 20 | return new \DateTime($this->data['nextSchedule']); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Context/Payment/Refund.php: -------------------------------------------------------------------------------- 1 | data['id'])); 19 | return $this->data['id']; 20 | } 21 | 22 | public function getReason(): ?string 23 | { 24 | \assert(is_string($this->data['reason']) || $this->data['reason'] === null); 25 | return $this->data['reason']; 26 | } 27 | 28 | public function getAmount(): CalculatedPrice 29 | { 30 | \assert(is_array($this->data['amount'])); 31 | return new CalculatedPrice($this->data['amount']); 32 | } 33 | 34 | public function getStateMachineState(): StateMachineState 35 | { 36 | \assert(is_array($this->data['stateMachineState'])); 37 | return new StateMachineState($this->data['stateMachineState']); 38 | } 39 | 40 | public function getTransactionCapture(): RefundTransactionCapture 41 | { 42 | \assert(is_array($this->data['transactionCapture'])); 43 | return new RefundTransactionCapture($this->data['transactionCapture']); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Context/Payment/RefundAction.php: -------------------------------------------------------------------------------- 1 | in the app manifest 13 | */ 14 | class RefundAction 15 | { 16 | public function __construct( 17 | public readonly ShopInterface $shop, 18 | public readonly ActionSource $source, 19 | public readonly Order $order, 20 | public readonly Refund $refund, 21 | ) { 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Context/Payment/RefundTransactionCapture.php: -------------------------------------------------------------------------------- 1 | data['externalReference']) || $this->data['externalReference'] === null); 19 | return $this->data['externalReference']; 20 | } 21 | 22 | public function getAmount(): CalculatedPrice 23 | { 24 | \assert(is_array($this->data['amount'])); 25 | return new CalculatedPrice($this->data['amount']); 26 | } 27 | 28 | public function getTransaction(): OrderTransaction 29 | { 30 | \assert(is_array($this->data['transaction'])); 31 | return new OrderTransaction($this->data['transaction']); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Context/Response/Customer/AddressResponseStruct.php: -------------------------------------------------------------------------------- 1 | sales channel -> domains 30 | */ 31 | public string $storefrontUrl; 32 | 33 | public ?string $requestedGroupId = null; 34 | 35 | public ?string $affiliateCode = null; 36 | 37 | public ?string $campaignCode = null; 38 | 39 | public ?int $birthdayDay = null; 40 | 41 | public ?int $birthdayMonth = null; 42 | 43 | public ?int $birthdayYear = null; 44 | 45 | /** 46 | * You'll need to set a password if you want to create a non-guest customer 47 | * Be aware to supply a plain password, it will be hashed before it is stored by the shop instance 48 | */ 49 | public ?string $password = null; 50 | 51 | public AddressResponseStruct $billingAddress; 52 | 53 | public ?AddressResponseStruct $shippingAddress = null; 54 | 55 | /** 56 | * @var string[] 57 | */ 58 | public array $vatIds = []; 59 | 60 | public bool $acceptedDataProtection = false; 61 | } 62 | -------------------------------------------------------------------------------- /src/Context/Response/ResponseStruct.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | public function jsonSerialize(): array 15 | { 16 | $data = \get_object_vars($this); 17 | 18 | foreach ($data as $key => $value) { 19 | if ($value instanceof JsonSerializable) { 20 | $data[$key] = $value->jsonSerialize(); 21 | } 22 | } 23 | 24 | return $data; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Context/SalesChannelContext/Address.php: -------------------------------------------------------------------------------- 1 | data['id'])); 17 | return $this->data['id']; 18 | } 19 | 20 | public function getSalutation(): ?Salutation 21 | { 22 | if (!isset($this->data['salutation'])) { 23 | return null; 24 | } 25 | 26 | \assert(is_array($this->data['salutation'])); 27 | return new Salutation($this->data['salutation']); 28 | } 29 | 30 | public function getFirstName(): string 31 | { 32 | \assert(is_string($this->data['firstName'])); 33 | return $this->data['firstName']; 34 | } 35 | 36 | public function getLastName(): string 37 | { 38 | \assert(is_string($this->data['lastName'])); 39 | return $this->data['lastName']; 40 | } 41 | 42 | public function getStreet(): string 43 | { 44 | \assert(is_string($this->data['street'])); 45 | return $this->data['street']; 46 | } 47 | 48 | public function getZipCode(): string 49 | { 50 | \assert(is_string($this->data['zipcode'])); 51 | return $this->data['zipcode']; 52 | } 53 | 54 | public function getCity(): string 55 | { 56 | \assert(is_string($this->data['city'])); 57 | return $this->data['city']; 58 | } 59 | 60 | public function getCompany(): ?string 61 | { 62 | if (!isset($this->data['company'])) { 63 | return null; 64 | } 65 | 66 | \assert(is_string($this->data['company'])); 67 | return $this->data['company']; 68 | } 69 | 70 | public function getDepartment(): ?string 71 | { 72 | if (!isset($this->data['department'])) { 73 | return null; 74 | } 75 | 76 | \assert(is_string($this->data['department'])); 77 | return $this->data['department']; 78 | } 79 | 80 | public function getTitle(): ?string 81 | { 82 | if (!isset($this->data['title'])) { 83 | return null; 84 | } 85 | 86 | \assert(is_string($this->data['title'])); 87 | return $this->data['title']; 88 | } 89 | 90 | public function getCountry(): Country 91 | { 92 | \assert(is_array($this->data['country'])); 93 | return new Country($this->data['country']); 94 | } 95 | 96 | public function getCountryState(): ?CountryState 97 | { 98 | if (!isset($this->data['countryState'])) { 99 | return null; 100 | } 101 | 102 | \assert(is_array($this->data['countryState'])); 103 | return new CountryState($this->data['countryState']); 104 | } 105 | 106 | public function getPhoneNumber(): ?string 107 | { 108 | if (!isset($this->data['phoneNumber'])) { 109 | return null; 110 | } 111 | 112 | \assert(is_string($this->data['phoneNumber'])); 113 | return $this->data['phoneNumber']; 114 | } 115 | 116 | public function getAdditionalAddressLine1(): ?string 117 | { 118 | if (!isset($this->data['additionalAddressLine1'])) { 119 | return null; 120 | } 121 | 122 | \assert(is_string($this->data['additionalAddressLine1'])); 123 | return $this->data['additionalAddressLine1']; 124 | } 125 | 126 | public function getAdditionalAddressLine2(): ?string 127 | { 128 | if (!isset($this->data['additionalAddressLine2'])) { 129 | return null; 130 | } 131 | 132 | \assert(is_string($this->data['additionalAddressLine2'])); 133 | return $this->data['additionalAddressLine2']; 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/Context/SalesChannelContext/Country.php: -------------------------------------------------------------------------------- 1 | data['name'])); 14 | return $this->data['name']; 15 | } 16 | 17 | public function getIso(): string 18 | { 19 | \assert(is_string($this->data['iso'])); 20 | return $this->data['iso']; 21 | } 22 | 23 | public function getIso3(): string 24 | { 25 | \assert(is_string($this->data['iso3'])); 26 | return $this->data['iso3']; 27 | } 28 | 29 | public function getCustomerTax(): TaxInfo 30 | { 31 | \assert(is_array($this->data['customerTax'])); 32 | return new TaxInfo($this->data['customerTax']); 33 | } 34 | 35 | public function getCompanyTax(): TaxInfo 36 | { 37 | \assert(is_array($this->data['companyTax'])); 38 | return new TaxInfo($this->data['companyTax']); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Context/SalesChannelContext/CountryState.php: -------------------------------------------------------------------------------- 1 | data['id'])); 17 | return $this->data['id']; 18 | } 19 | 20 | public function getName(): string 21 | { 22 | \assert(is_string($this->data['name'])); 23 | return $this->data['name']; 24 | } 25 | 26 | public function getShortCode(): string 27 | { 28 | \assert(is_string($this->data['shortCode'])); 29 | return $this->data['shortCode']; 30 | } 31 | 32 | public function getPosition(): int 33 | { 34 | \assert(is_int($this->data['position'])); 35 | return $this->data['position']; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Context/SalesChannelContext/Currency.php: -------------------------------------------------------------------------------- 1 | data['id'])); 17 | return $this->data['id']; 18 | } 19 | 20 | public function getIsoCode(): string 21 | { 22 | \assert(is_string($this->data['isoCode'])); 23 | return $this->data['isoCode']; 24 | } 25 | 26 | public function getFactor(): float 27 | { 28 | \assert(is_float($this->data['factor']) || is_int($this->data['factor'])); 29 | return $this->data['factor']; 30 | } 31 | 32 | public function getSymbol(): string 33 | { 34 | \assert(is_string($this->data['symbol'])); 35 | return $this->data['symbol']; 36 | } 37 | 38 | public function getShortName(): string 39 | { 40 | \assert(is_string($this->data['shortName'])); 41 | return $this->data['shortName']; 42 | } 43 | 44 | public function getName(): string 45 | { 46 | \assert(is_string($this->data['name'])); 47 | return $this->data['name']; 48 | } 49 | 50 | public function getItemRounding(): RoundingConfig 51 | { 52 | \assert(is_array($this->data['itemRounding'])); 53 | return new RoundingConfig($this->data['itemRounding']); 54 | } 55 | 56 | public function getTotalRounding(): RoundingConfig 57 | { 58 | \assert(is_array($this->data['totalRounding'])); 59 | return new RoundingConfig($this->data['totalRounding']); 60 | } 61 | 62 | public function getTaxFreeFrom(): float 63 | { 64 | \assert(is_float($this->data['taxFreeFrom']) || is_int($this->data['taxFreeFrom'])); 65 | return (float) $this->data['taxFreeFrom']; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Context/SalesChannelContext/Customer.php: -------------------------------------------------------------------------------- 1 | data['id'])); 17 | return $this->data['id']; 18 | } 19 | 20 | public function getFirstName(): string 21 | { 22 | \assert(is_string($this->data['firstName'])); 23 | return $this->data['firstName']; 24 | } 25 | 26 | public function getLastName(): string 27 | { 28 | \assert(is_string($this->data['lastName'])); 29 | return $this->data['lastName']; 30 | } 31 | 32 | public function getEmail(): string 33 | { 34 | \assert(is_string($this->data['email'])); 35 | return $this->data['email']; 36 | } 37 | 38 | public function getCompany(): ?string 39 | { 40 | \assert(is_string($this->data['company']) || is_null($this->data['company'])); 41 | return $this->data['company']; 42 | } 43 | 44 | public function getCustomerNumber(): string 45 | { 46 | \assert(is_string($this->data['customerNumber'])); 47 | return $this->data['customerNumber']; 48 | } 49 | 50 | public function getTitle(): ?string 51 | { 52 | \assert(is_string($this->data['title']) || is_null($this->data['title'])); 53 | return $this->data['title']; 54 | } 55 | 56 | public function isActive(): bool 57 | { 58 | \assert(is_bool($this->data['active'])); 59 | return $this->data['active']; 60 | } 61 | 62 | public function isGuest(): bool 63 | { 64 | \assert(is_bool($this->data['guest'])); 65 | return $this->data['guest']; 66 | } 67 | 68 | public function getAccountType(): string 69 | { 70 | \assert(is_string($this->data['accountType'])); 71 | return $this->data['accountType']; 72 | } 73 | 74 | /** 75 | * @return array 76 | */ 77 | public function getVatIds(): array 78 | { 79 | \assert(is_array($this->data['vatIds']) || is_null($this->data['vatIds'])); 80 | return $this->data['vatIds'] ?? []; 81 | } 82 | 83 | public function getRemoteAddress(): string 84 | { 85 | \assert(is_string($this->data['remoteAddress'])); 86 | return $this->data['remoteAddress']; 87 | } 88 | 89 | public function getSalutation(): ?Salutation 90 | { 91 | \assert(is_array($this->data['salutation']) || is_null($this->data['salutation'])); 92 | 93 | if ($this->data['salutation'] === null) { 94 | return null; 95 | } 96 | 97 | return new Salutation($this->data['salutation']); 98 | } 99 | 100 | public function getDefaultPaymentMethod(): PaymentMethod 101 | { 102 | \assert(is_array($this->data['defaultPaymentMethod'])); 103 | return new PaymentMethod($this->data['defaultPaymentMethod']); 104 | } 105 | 106 | public function getDefaultBillingAddress(): Address 107 | { 108 | \assert(is_array($this->data['defaultBillingAddress'])); 109 | return new Address($this->data['defaultBillingAddress']); 110 | } 111 | 112 | public function getDefaultShippingAddress(): Address 113 | { 114 | \assert(is_array($this->data['defaultShippingAddress'])); 115 | return new Address($this->data['defaultShippingAddress']); 116 | } 117 | 118 | public function getActiveBillingAddress(): Address 119 | { 120 | if (\array_key_exists('activeBillingAddress', $this->data)) { 121 | \assert(is_array($this->data['activeBillingAddress'])); 122 | return new Address($this->data['activeBillingAddress']); 123 | } 124 | 125 | return $this->getDefaultBillingAddress(); 126 | } 127 | 128 | public function getActiveShippingAddress(): Address 129 | { 130 | if (\array_key_exists('activeShippingAddress', $this->data)) { 131 | \assert(is_array($this->data['activeShippingAddress'])); 132 | return new Address($this->data['activeShippingAddress']); 133 | } 134 | 135 | return $this->getDefaultShippingAddress(); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/Context/SalesChannelContext/PaymentMethod.php: -------------------------------------------------------------------------------- 1 | data['id'])); 14 | return $this->data['id']; 15 | } 16 | 17 | public function getName(): string 18 | { 19 | \assert(is_string($this->data['name'])); 20 | return $this->data['name']; 21 | } 22 | 23 | /** 24 | * @since Shopware v6.7.0.0 25 | */ 26 | public function getTechnicalName(): string 27 | { 28 | \assert(is_string($this->data['technicalName']) || is_null($this->data['technicalName'])); 29 | return $this->data['technicalName'] ?? ''; 30 | } 31 | 32 | public function getDescription(): string 33 | { 34 | \assert(is_string($this->data['description'])); 35 | return $this->data['description']; 36 | } 37 | 38 | public function isActive(): bool 39 | { 40 | \assert(is_bool($this->data['active'])); 41 | return $this->data['active']; 42 | } 43 | 44 | public function isAfterOrderEnabled(): bool 45 | { 46 | \assert(is_bool($this->data['afterOrderEnabled'])); 47 | return $this->data['afterOrderEnabled']; 48 | } 49 | 50 | public function getAvailabilityRuleId(): ?string 51 | { 52 | \assert(is_string($this->data['availabilityRuleId']) || is_null($this->data['availabilityRuleId'])); 53 | return $this->data['availabilityRuleId']; 54 | } 55 | 56 | public function isSynchronous(): bool 57 | { 58 | \assert(is_bool($this->data['synchronous'])); 59 | return $this->data['synchronous']; 60 | } 61 | 62 | public function isAsynchronous(): bool 63 | { 64 | \assert(is_bool($this->data['asynchronous'])); 65 | return $this->data['asynchronous']; 66 | } 67 | 68 | public function isPrepared(): bool 69 | { 70 | \assert(is_bool($this->data['prepared'])); 71 | return $this->data['prepared']; 72 | } 73 | 74 | public function isRefundable(): bool 75 | { 76 | \assert(is_bool($this->data['refundable'])); 77 | return $this->data['refundable']; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Context/SalesChannelContext/RoundingConfig.php: -------------------------------------------------------------------------------- 1 | data['decimals'])); 17 | return $this->data['decimals']; 18 | } 19 | 20 | 21 | /** 22 | * In which interval should be rounded 23 | */ 24 | public function getInterval(): float 25 | { 26 | \assert(is_float($this->data['interval'])); 27 | return $this->data['interval']; 28 | } 29 | 30 | public function isRoundForNet(): bool 31 | { 32 | \assert(is_bool($this->data['roundForNet'])); 33 | return $this->data['roundForNet']; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Context/SalesChannelContext/SalesChannel.php: -------------------------------------------------------------------------------- 1 | data['id'])); 15 | return $this->data['id']; 16 | } 17 | 18 | public function getName(): string 19 | { 20 | \assert(is_string($this->data['name'])); 21 | return $this->data['name']; 22 | } 23 | 24 | public function getAccessKey(): string 25 | { 26 | \assert(is_string($this->data['accessKey'])); 27 | return $this->data['accessKey']; 28 | } 29 | 30 | public function getTaxCalculationType(): string 31 | { 32 | \assert(is_string($this->data['taxCalculationType'])); 33 | return $this->data['taxCalculationType']; 34 | } 35 | 36 | public function getCurrency(): Currency 37 | { 38 | \assert(is_array($this->data['currency'])); 39 | return new Currency($this->data['currency']); 40 | } 41 | 42 | /** 43 | * @return Collection 44 | */ 45 | public function getDomains(): Collection 46 | { 47 | \assert(is_array($this->data['domains'])); 48 | 49 | return new Collection(\array_map(static function (array $domain): SalesChannelDomain { 50 | return new SalesChannelDomain($domain); 51 | }, $this->data['domains'])); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Context/SalesChannelContext/SalesChannelContext.php: -------------------------------------------------------------------------------- 1 | data['token'])); 17 | return $this->data['token']; 18 | } 19 | 20 | public function getCurrencyId(): string 21 | { 22 | \assert(is_array($this->data['context']) && is_string($this->data['context']['currencyId'])); 23 | return $this->data['context']['currencyId']; 24 | } 25 | 26 | /** 27 | * @return string 28 | */ 29 | public function getTaxState(): string 30 | { 31 | \assert(is_array($this->data['context']) && is_string($this->data['context']['taxState'])); 32 | return $this->data['context']['taxState']; 33 | } 34 | 35 | public function getRounding(): RoundingConfig 36 | { 37 | \assert(is_array($this->data['context']) && is_array($this->data['context']['rounding'])); 38 | return new RoundingConfig($this->data['context']['rounding']); 39 | } 40 | 41 | public function getCurrency(): Currency 42 | { 43 | \assert(is_array($this->data['currency'])); 44 | return new Currency($this->data['currency']); 45 | } 46 | 47 | public function getShippingMethod(): ShippingMethod 48 | { 49 | \assert(is_array($this->data['shippingMethod'])); 50 | return new ShippingMethod($this->data['shippingMethod']); 51 | } 52 | 53 | public function getPaymentMethod(): PaymentMethod 54 | { 55 | \assert(is_array($this->data['paymentMethod'])); 56 | return new PaymentMethod($this->data['paymentMethod']); 57 | } 58 | 59 | public function getSalesChannel(): SalesChannel 60 | { 61 | \assert(is_array($this->data['salesChannel'])); 62 | return new SalesChannel($this->data['salesChannel']); 63 | } 64 | 65 | public function getCustomer(): ?Customer 66 | { 67 | \assert(is_array($this->data['customer']) || is_null($this->data['customer'])); 68 | 69 | if (is_null($this->data['customer'])) { 70 | return null; 71 | } 72 | 73 | return new Customer($this->data['customer']); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Context/SalesChannelContext/SalesChannelDomain.php: -------------------------------------------------------------------------------- 1 | data['id'])); 17 | return $this->data['id']; 18 | } 19 | 20 | public function getUrl(): string 21 | { 22 | \assert(is_string($this->data['url'])); 23 | return $this->data['url']; 24 | } 25 | 26 | public function getLanguageId(): string 27 | { 28 | \assert(is_string($this->data['languageId'])); 29 | return $this->data['languageId']; 30 | } 31 | 32 | public function getCurrencyId(): string 33 | { 34 | \assert(is_string($this->data['currencyId'])); 35 | return $this->data['currencyId']; 36 | } 37 | 38 | public function getSnippetSetId(): string 39 | { 40 | \assert(is_string($this->data['snippetSetId'])); 41 | return $this->data['snippetSetId']; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Context/SalesChannelContext/Salutation.php: -------------------------------------------------------------------------------- 1 | data['id'])); 14 | return $this->data['id']; 15 | } 16 | 17 | public function getDisplayName(): string 18 | { 19 | \assert(is_string($this->data['displayName'])); 20 | return $this->data['displayName']; 21 | } 22 | 23 | public function getLetterName(): string 24 | { 25 | \assert(is_string($this->data['letterName'])); 26 | return $this->data['letterName']; 27 | } 28 | 29 | public function getSalutationKey(): string 30 | { 31 | \assert(is_string($this->data['salutationKey'])); 32 | return $this->data['salutationKey']; 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /src/Context/SalesChannelContext/ShippingLocation.php: -------------------------------------------------------------------------------- 1 | data['country'])); 14 | return new Country($this->data['country']); 15 | } 16 | 17 | public function getCountryState(): ?CountryState 18 | { 19 | if (is_null($this->data['countryState'] ?? null)) { 20 | return null; 21 | } 22 | 23 | \assert(is_array($this->data['countryState'])); 24 | return new CountryState($this->data['countryState']); 25 | } 26 | 27 | public function getAddress(): Address 28 | { 29 | \assert(is_array($this->data['address'])); 30 | return new Address($this->data['address']); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Context/SalesChannelContext/ShippingMethod.php: -------------------------------------------------------------------------------- 1 | data['id'])); 14 | return $this->data['id']; 15 | } 16 | 17 | public function getName(): string 18 | { 19 | \assert(is_string($this->data['name'])); 20 | return $this->data['name']; 21 | } 22 | 23 | /** 24 | * @since Shopware v6.7.0.0 25 | */ 26 | public function getTechnicalName(): string 27 | { 28 | \assert(is_string($this->data['technicalName']) || is_null($this->data['technicalName'])); 29 | return $this->data['technicalName'] ?? ''; 30 | } 31 | 32 | public function getTaxType(): string 33 | { 34 | \assert(is_string($this->data['taxType'])); 35 | return $this->data['taxType']; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Context/SalesChannelContext/TaxInfo.php: -------------------------------------------------------------------------------- 1 | data['enabled'])); 14 | return $this->data['enabled']; 15 | } 16 | 17 | public function getCurrencyId(): string 18 | { 19 | \assert(is_string($this->data['currencyId'])); 20 | return $this->data['currencyId']; 21 | } 22 | 23 | public function getAmount(): float 24 | { 25 | \assert(is_float($this->data['amount']) || is_int($this->data['amount'])); 26 | return $this->data['amount']; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Context/Storefront/StorefrontAction.php: -------------------------------------------------------------------------------- 1 | $inAppPurchases 15 | */ 16 | public function __construct( 17 | public readonly ShopInterface $shop, 18 | public readonly StorefrontClaims $claims, 19 | public readonly Collection $inAppPurchases, 20 | ) { 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Context/Storefront/StorefrontClaims.php: -------------------------------------------------------------------------------- 1 | claims['salesChannelId'] ?? null; 32 | if (!is_string($value)) { 33 | throw new MissingClaimException('salesChannelId'); 34 | } 35 | 36 | return $value; 37 | } 38 | 39 | public function getCustomerId(): string 40 | { 41 | $value = $this->claims['customerId'] ?? null; 42 | if (!is_string($value)) { 43 | throw new MissingClaimException('customerId'); 44 | } 45 | 46 | return $value; 47 | } 48 | 49 | public function getCurrencyId(): string 50 | { 51 | $value = $this->claims['currencyId'] ?? null; 52 | if (!is_string($value)) { 53 | throw new MissingClaimException('currencyId'); 54 | } 55 | 56 | return $value; 57 | } 58 | 59 | public function getLanguageId(): string 60 | { 61 | $value = $this->claims['languageId'] ?? null; 62 | if (!is_string($value)) { 63 | throw new MissingClaimException('languageId'); 64 | } 65 | 66 | return $value; 67 | } 68 | 69 | public function getPaymentMethodId(): string 70 | { 71 | $value = $this->claims['paymentMethodId'] ?? null; 72 | if (!is_string($value)) { 73 | throw new MissingClaimException('paymentMethodId'); 74 | } 75 | 76 | return $value; 77 | } 78 | 79 | public function getShippingMethodId(): string 80 | { 81 | $value = $this->claims['shippingMethodId'] ?? null; 82 | if (!is_string($value)) { 83 | throw new MissingClaimException('shippingMethodId'); 84 | } 85 | 86 | return $value; 87 | } 88 | 89 | public function getInAppPurchases(): string 90 | { 91 | $value = $this->claims['inAppPurchases'] ?? null; 92 | if (!is_string($value)) { 93 | throw new MissingClaimException('inAppPurchases'); 94 | } 95 | 96 | return $value; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Context/TaxProvider/TaxProviderAction.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | public function getCustomFields(): array 13 | { 14 | \assert(is_array($this->data['customFields']) || $this->data['customFields'] === null); 15 | return $this->data['customFields'] ?? []; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Context/Webhook/WebhookAction.php: -------------------------------------------------------------------------------- 1 | $payload 14 | */ 15 | public function __construct( 16 | public readonly ShopInterface $shop, 17 | public readonly ActionSource $source, 18 | public readonly string $eventName, 19 | public readonly array $payload, 20 | public readonly \DateTimeInterface $timestamp 21 | ) { 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Event/AbstractAppLifecycleEvent.php: -------------------------------------------------------------------------------- 1 | request; 19 | } 20 | 21 | public function getShop(): ShopInterface 22 | { 23 | return $this->shop; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Event/BeforeRegistrationCompletedEvent.php: -------------------------------------------------------------------------------- 1 | confirmation; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Event/BeforeRegistrationStartsEvent.php: -------------------------------------------------------------------------------- 1 | request; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Exception/SignatureNotFoundException.php: -------------------------------------------------------------------------------- 1 | request; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Framework/Collection.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | class Collection implements Countable, IteratorAggregate, JsonSerializable 18 | { 19 | /** 20 | * @var array 21 | */ 22 | protected array $elements = []; 23 | 24 | /** 25 | * @param iterable $elements 26 | */ 27 | public function __construct(iterable $elements = []) 28 | { 29 | /** 30 | * @var array-key|null $key 31 | */ 32 | foreach ($elements as $key => $element) { 33 | $this->set($key, $element); 34 | } 35 | } 36 | 37 | /** 38 | * @return array 39 | */ 40 | public function all(): array 41 | { 42 | return $this->elements; 43 | } 44 | 45 | /** 46 | * @param array-key|null $key 47 | * @param TElement $element 48 | */ 49 | public function set(int|string|null $key, $element): void 50 | { 51 | $key === null ? $this->elements[] = $element : $this->elements[$key] = $element; 52 | } 53 | 54 | /** 55 | * @param TElement $element 56 | */ 57 | public function add($element): void 58 | { 59 | $this->elements[] = $element; 60 | } 61 | 62 | /** 63 | * @param array-key $key 64 | * 65 | * @return TElement|null 66 | */ 67 | public function get(int|string $key): mixed 68 | { 69 | if ($this->has($key)) { 70 | return $this->elements[$key]; 71 | } 72 | 73 | return null; 74 | } 75 | 76 | /** 77 | * @return TElement|null 78 | */ 79 | public function first(): mixed 80 | { 81 | return $this->elements[\array_key_first($this->elements)] ?? null; 82 | } 83 | 84 | /** 85 | * @return TElement|null 86 | */ 87 | public function last(): mixed 88 | { 89 | return $this->elements[array_key_last($this->elements)] ?? null; 90 | } 91 | 92 | /** 93 | * @param array-key $key 94 | */ 95 | public function remove(int|string $key): void 96 | { 97 | unset($this->elements[$key]); 98 | } 99 | 100 | /** 101 | * @param array-key $key 102 | */ 103 | public function has(int|string $key): bool 104 | { 105 | return \array_key_exists($key, $this->elements); 106 | } 107 | 108 | /** 109 | * @return array 110 | */ 111 | public function map(\Closure $closure): array 112 | { 113 | return \array_map($closure, $this->elements); 114 | } 115 | 116 | /** 117 | * @return self 118 | */ 119 | public function filter(\Closure $closure): self 120 | { 121 | return new Collection(\array_filter($this->elements, $closure)); 122 | } 123 | 124 | /** 125 | * @return Traversable 126 | */ 127 | public function getIterator(): Traversable 128 | { 129 | yield from $this->elements; 130 | } 131 | 132 | public function count(): int 133 | { 134 | return \count($this->elements); 135 | } 136 | 137 | /** 138 | * @return array 139 | */ 140 | public function keys(): array 141 | { 142 | return \array_keys($this->elements); 143 | } 144 | 145 | /** 146 | * @return array 147 | */ 148 | public function jsonSerialize(): array 149 | { 150 | $result = []; 151 | 152 | foreach ($this->elements as $key => $element) { 153 | if ($element instanceof JsonSerializable) { 154 | $result[$key] = $element->jsonSerialize(); 155 | continue; 156 | } 157 | 158 | $result[$key] = $element; 159 | } 160 | 161 | return $result; 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/Gateway/Checkout/CheckoutGatewayCommand.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | public array $payload = []; 13 | 14 | public string $keyName; 15 | 16 | public function setPayloadValue(string $key, mixed $value): void 17 | { 18 | $this->payload[$key] = $value; 19 | } 20 | 21 | public function hasPayloadValue(string $key): bool 22 | { 23 | return isset($this->payload[$key]); 24 | } 25 | 26 | public function getPayloadValue(string $key): mixed 27 | { 28 | if (!$this->hasPayloadValue($key)) { 29 | return null; 30 | } 31 | 32 | return $this->payload[$key]; 33 | } 34 | 35 | /** 36 | * @return array{command: string, payload: array} 37 | */ 38 | public function jsonSerialize(): array 39 | { 40 | return [ 41 | 'command' => $this->keyName, 42 | 'payload' => $this->payload, 43 | ]; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Gateway/Checkout/Command/AddCartErrorCommand.php: -------------------------------------------------------------------------------- 1 | keyName = self::KEY; 30 | $this->setPayloadValue('message', $message); 31 | $this->setPayloadValue('blocking', $blocking); 32 | $this->setPayloadValue('level', $level); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Gateway/Checkout/Command/AddPaymentMethodCommand.php: -------------------------------------------------------------------------------- 1 | keyName = self::KEY; 19 | $this->setPayloadValue('paymentMethodTechnicalName', $paymentMethodTechnicalName); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Gateway/Checkout/Command/AddPaymentMethodExtensionCommand.php: -------------------------------------------------------------------------------- 1 | $extensionsPayload - The payload of the newly to be added extension 21 | */ 22 | public function __construct( 23 | public readonly string $paymentMethodTechnicalName, 24 | public readonly string $extensionKey, 25 | public readonly array $extensionsPayload, 26 | ) { 27 | $this->keyName = self::KEY; 28 | $this->setPayloadValue('paymentMethodTechnicalName', $paymentMethodTechnicalName); 29 | $this->setPayloadValue('extensionKey', $extensionKey); 30 | $this->setPayloadValue('extensionsPayload', $extensionsPayload); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Gateway/Checkout/Command/AddShippingMethodCommand.php: -------------------------------------------------------------------------------- 1 | keyName = self::KEY; 19 | $this->setPayloadValue('shippingMethodTechnicalName', $shippingMethodTechnicalName); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Gateway/Checkout/Command/AddShippingMethodExtensionCommand.php: -------------------------------------------------------------------------------- 1 | $extensionsPayload - The payload of the newly to be added extension 21 | */ 22 | public function __construct( 23 | public readonly string $shippingMethodTechnicalName, 24 | public readonly string $extensionKey, 25 | public readonly array $extensionsPayload, 26 | ) { 27 | $this->keyName = self::KEY; 28 | $this->setPayloadValue('shippingMethodTechnicalName', $shippingMethodTechnicalName); 29 | $this->setPayloadValue('extensionKey', $extensionKey); 30 | $this->setPayloadValue('extensionsPayload', $extensionsPayload); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Gateway/Checkout/Command/RemovePaymentMethodCommand.php: -------------------------------------------------------------------------------- 1 | keyName = self::KEY; 24 | $this->setPayloadValue('paymentMethodTechnicalName', $paymentMethodTechnicalName); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Gateway/Checkout/Command/RemoveShippingMethodCommand.php: -------------------------------------------------------------------------------- 1 | keyName = self::KEY; 24 | $this->setPayloadValue('shippingMethodTechnicalName', $shippingMethodTechnicalName); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Gateway/Context/Command/AddCustomerMessageCommand.php: -------------------------------------------------------------------------------- 1 | keyName = self::KEY; 23 | $this->setPayloadValue('message', $message); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Gateway/Context/Command/ChangeBillingAddressCommand.php: -------------------------------------------------------------------------------- 1 | keyName = self::KEY; 24 | $this->setPayloadValue('addressId', $addressId); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Gateway/Context/Command/ChangeCurrencyCommand.php: -------------------------------------------------------------------------------- 1 | keyName = self::KEY; 24 | $this->setPayloadValue('iso', $iso); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Gateway/Context/Command/ChangeLanguageCommand.php: -------------------------------------------------------------------------------- 1 | keyName = self::KEY; 24 | $this->setPayloadValue('iso', $iso); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Gateway/Context/Command/ChangePaymentMethodCommand.php: -------------------------------------------------------------------------------- 1 | keyName = self::KEY; 24 | $this->setPayloadValue('technicalName', $technicalName); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Gateway/Context/Command/ChangeShippingAddressCommand.php: -------------------------------------------------------------------------------- 1 | keyName = self::KEY; 24 | $this->setPayloadValue('addressId', $addressId); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Gateway/Context/Command/ChangeShippingLocationCommand.php: -------------------------------------------------------------------------------- 1 | keyName = self::KEY; 27 | $this->setPayloadValue('countryIso', $countryIso); 28 | $this->setPayloadValue('countryStateIso', $countryStateIso); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Gateway/Context/Command/ChangeShippingMethodCommand.php: -------------------------------------------------------------------------------- 1 | keyName = self::KEY; 24 | $this->setPayloadValue('technicalName', $technicalName); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Gateway/Context/Command/LoginCustomerCommand.php: -------------------------------------------------------------------------------- 1 | keyName = self::KEY; 23 | $this->setPayloadValue('customerEmail', $customerEmail); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Gateway/Context/Command/RegisterCustomerCommand.php: -------------------------------------------------------------------------------- 1 | keyName = self::KEY; 24 | $this->setPayloadValue('data', $data); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Gateway/Context/ContextGatewayCommand.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | public array $payload = []; 13 | 14 | protected string $keyName; 15 | 16 | public function getKey(): string 17 | { 18 | return $this->keyName; 19 | } 20 | 21 | public function setPayloadValue(string $key, mixed $value): void 22 | { 23 | $this->payload[$key] = $value; 24 | } 25 | 26 | public function hasPayloadValue(string $key): bool 27 | { 28 | return isset($this->payload[$key]); 29 | } 30 | 31 | public function getPayloadValue(string $key): mixed 32 | { 33 | if (!$this->hasPayloadValue($key)) { 34 | return null; 35 | } 36 | 37 | return $this->payload[$key]; 38 | } 39 | 40 | /** 41 | * @return array{command: string, payload: array} 42 | */ 43 | public function jsonSerialize(): array 44 | { 45 | return [ 46 | 'command' => $this->keyName, 47 | 'payload' => $this->payload, 48 | ]; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/HttpClient/AuthenticatedClient.php: -------------------------------------------------------------------------------- 1 | client->sendRequest($request->withHeader('Authorization', 'Bearer ' . $this->fetchAccessToken())); 29 | } 30 | 31 | private function fetchAccessToken(): string 32 | { 33 | $cacheKey = $this->shop->getShopId() . '-access-token'; 34 | 35 | $value = $this->cache->get($cacheKey); 36 | if (is_string($value)) { 37 | return $value; 38 | } 39 | 40 | $response = $this->client->sendRequest($this->createTokenRequest($this->shop)); 41 | 42 | if ($response->getStatusCode() !== 200) { 43 | throw new AuthenticationFailedException($this->shop->getShopId(), $response); 44 | } 45 | 46 | /** @var array{access_token: string, expires_in: int} $token */ 47 | $token = json_decode($response->getBody()->getContents(), true); 48 | 49 | $this->cache->set($cacheKey, $token['access_token'], $token['expires_in'] - self::TOKEN_EXPIRE_DIFF); 50 | 51 | return $token['access_token']; 52 | } 53 | 54 | /** 55 | * @throws \JsonException 56 | */ 57 | private function createTokenRequest(ShopInterface $shop): RequestInterface 58 | { 59 | $factory = new Psr17Factory(); 60 | $request = $factory->createRequest('POST', sprintf('%s/api/oauth/token', $shop->getShopUrl())); 61 | 62 | return $request 63 | ->withHeader('Content-Type', 'application/json') 64 | ->withBody($factory->createStream(json_encode([ 65 | 'grant_type' => 'client_credentials', 66 | 'client_id' => $shop->getShopClientId(), 67 | 'client_secret' => $shop->getShopClientSecret(), 68 | ], JSON_THROW_ON_ERROR))); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/HttpClient/ClientFactory.php: -------------------------------------------------------------------------------- 1 | client, $this->logger), $shop, $this->cache); 27 | } 28 | 29 | public function createSimpleClient(ShopInterface $shop): SimpleHttpClient 30 | { 31 | return new SimpleHttpClient($this->createClient($shop)); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/HttpClient/Exception/AuthenticationFailedException.php: -------------------------------------------------------------------------------- 1 | response; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/HttpClient/LoggerClient.php: -------------------------------------------------------------------------------- 1 | client->sendRequest($request); 21 | 22 | $this->logger->debug('Request body', ['body' => $request->getBody()->getContents()]); 23 | $request->getBody()->rewind(); 24 | 25 | $this->logger->info(sprintf('Request: %s %s', $request->getMethod(), $request->getUri()), [ 26 | 'request' => [ 27 | 'method' => $request->getMethod(), 28 | 'uri' => $request->getUri(), 29 | 'headers' => $request->getHeaders(), 30 | ], 31 | 'response' => [ 32 | 'status' => $response->getStatusCode(), 33 | 'headers' => $response->getHeaders(), 34 | ] 35 | ]); 36 | 37 | $this->logger->debug('Response body', ['body' => $response->getBody()->getContents()]); 38 | $response->getBody()->rewind(); 39 | 40 | return $response; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/HttpClient/NullCache.php: -------------------------------------------------------------------------------- 1 | $values 38 | */ 39 | public function setMultiple(iterable $values, \DateInterval|int|null $ttl = null): bool 40 | { 41 | return true; 42 | } 43 | 44 | public function deleteMultiple(iterable $keys): bool 45 | { 46 | return true; 47 | } 48 | 49 | public function has(string $key): bool 50 | { 51 | return false; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/HttpClient/SimpleHttpClient/SimpleHttpClient.php: -------------------------------------------------------------------------------- 1 | $headers 19 | */ 20 | public function get(string $url, array $headers = []): SimpleHttpClientResponse 21 | { 22 | $request = $this->createRequest('GET', $url, $headers); 23 | 24 | $response = $this->client->sendRequest($request); 25 | 26 | return new SimpleHttpClientResponse($response); 27 | } 28 | 29 | /** 30 | * @param array $body 31 | * @param array $headers 32 | */ 33 | public function post(string $url, array $body = [], array $headers = []): SimpleHttpClientResponse 34 | { 35 | return $this->doRequest('POST', $url, $body, $headers); 36 | } 37 | 38 | /** 39 | * @param array $body 40 | * @param array $headers 41 | */ 42 | public function patch(string $url, array $body = [], array $headers = []): SimpleHttpClientResponse 43 | { 44 | return $this->doRequest('PATCH', $url, $body, $headers); 45 | } 46 | 47 | /** 48 | * @param array $body 49 | * @param array $headers 50 | */ 51 | public function put(string $url, array $body = [], array $headers = []): SimpleHttpClientResponse 52 | { 53 | return $this->doRequest('PUT', $url, $body, $headers); 54 | } 55 | 56 | /** 57 | * @param array $body 58 | * @param array $headers 59 | */ 60 | public function delete(string $url, array $body = [], array $headers = []): SimpleHttpClientResponse 61 | { 62 | return $this->doRequest('DELETE', $url, $body, $headers); 63 | } 64 | 65 | 66 | /** 67 | * @param array $body 68 | * @param array $headers 69 | */ 70 | private function doRequest(string $method, string $url, array $body = [], array $headers = []): SimpleHttpClientResponse 71 | { 72 | $factory = new Psr17Factory(); 73 | 74 | $request = $this->createRequest($method, $url, $headers); 75 | $request = $request->withBody( 76 | $factory->createStream(json_encode($body, JSON_THROW_ON_ERROR)) 77 | ); 78 | 79 | $response = $this->client->sendRequest($request); 80 | 81 | return new SimpleHttpClientResponse($response); 82 | } 83 | 84 | /** 85 | * @param array $headers 86 | */ 87 | private function createRequest(string $method, string $url, array $headers = []): RequestInterface 88 | { 89 | $factory = new Psr17Factory(); 90 | $request = $factory->createRequest($method, $url); 91 | 92 | // will be overwritten by the headers passed in the arguments 93 | $request = $request 94 | ->withHeader('Accept', 'application/json') 95 | ->withHeader('Content-Type', 'application/json'); 96 | 97 | foreach ($headers as $headerName => $headerValue) { 98 | $request = $request->withHeader($headerName, $headerValue); 99 | } 100 | 101 | return $request; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/HttpClient/SimpleHttpClient/SimpleHttpClientResponse.php: -------------------------------------------------------------------------------- 1 | response->getBody()->getContents(); 18 | $this->response->getBody()->rewind(); 19 | 20 | return $contents; 21 | } 22 | 23 | /** 24 | * @return array 25 | */ 26 | public function json(): array 27 | { 28 | $data = \json_decode($this->getContent(), true, flags: JSON_THROW_ON_ERROR); 29 | 30 | if (!is_array($data)) { 31 | throw new \RuntimeException('Response is not a valid JSON array'); 32 | } 33 | 34 | return $data; 35 | } 36 | 37 | public function getStatusCode(): int 38 | { 39 | return $this->response->getStatusCode(); 40 | } 41 | 42 | public function getHeader(string $name): string 43 | { 44 | return $this->response->getHeaderLine($name); 45 | } 46 | 47 | public function getRawResponse(): ResponseInterface 48 | { 49 | return $this->response; 50 | } 51 | 52 | public function ok(): bool 53 | { 54 | return $this->getStatusCode() >= 200 && $this->getStatusCode() < 300; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Registration/RandomStringShopSecretGenerator.php: -------------------------------------------------------------------------------- 1 | $shopRepository 31 | */ 32 | public function __construct( 33 | private readonly AppConfiguration $appConfiguration, 34 | private readonly ShopRepositoryInterface $shopRepository, 35 | private readonly RequestVerifier $requestVerifier = new RequestVerifier(), 36 | private readonly ResponseSigner $responseSigner = new ResponseSigner(), 37 | private readonly ShopSecretGeneratorInterface $shopSecretGeneratorInterface = new RandomStringShopSecretGenerator(), 38 | private readonly LoggerInterface $logger = new NullLogger(), 39 | private readonly ?EventDispatcherInterface $eventDispatcher = null 40 | ) { 41 | } 42 | 43 | /** 44 | * @throws SignatureNotFoundException 45 | * @throws SignatureInvalidException 46 | */ 47 | public function register(RequestInterface $request): ResponseInterface 48 | { 49 | $this->requestVerifier->authenticateRegistrationRequest($request, $this->appConfiguration); 50 | 51 | \parse_str($request->getUri()->getQuery(), $queries); 52 | 53 | if (!isset($queries['shop-id'], $queries['shop-url']) || !is_string($queries['shop-id']) || !is_string($queries['shop-url'])) { 54 | throw new MissingShopParameterException(); 55 | } 56 | 57 | $shop = $this->shopRepository->getShopFromId($queries['shop-id']); 58 | 59 | if ($shop === null) { 60 | $shop = $this->shopRepository->createShopStruct( 61 | $queries['shop-id'], 62 | $queries['shop-url'], 63 | $this->shopSecretGeneratorInterface->generate() 64 | ); 65 | 66 | $sanitizedShop = $this->getSanitizedShop($shop); 67 | $this->eventDispatcher?->dispatch(new BeforeRegistrationStartsEvent($request, $sanitizedShop)); 68 | 69 | $this->shopRepository->createShop($sanitizedShop); 70 | } else { 71 | $shop->setShopUrl($queries['shop-url']); 72 | 73 | $sanitizedShop = $this->getSanitizedShop($shop); 74 | $this->eventDispatcher?->dispatch(new BeforeRegistrationStartsEvent($request, $sanitizedShop)); 75 | 76 | $this->shopRepository->updateShop($sanitizedShop); 77 | } 78 | 79 | $this->logger->info('Shop registration request received', [ 80 | 'shop-id' => $sanitizedShop->getShopId(), 81 | 'shop-url' => $sanitizedShop->getShopUrl(), 82 | ]); 83 | 84 | $psrFactory = new Psr17Factory(); 85 | 86 | $data = [ 87 | // old shop is needed because the shop url is not sanitized 88 | 'proof' => $this->responseSigner->getRegistrationSignature($this->appConfiguration, $shop), 89 | 'confirmation_url' => $this->appConfiguration->getRegistrationConfirmUrl(), 90 | 'secret' => $shop->getShopSecret(), 91 | ]; 92 | 93 | $response = $psrFactory->createResponse(200); 94 | 95 | return $response 96 | ->withHeader('Content-Type', 'application/json') 97 | ->withBody($psrFactory->createStream(\json_encode($data, JSON_THROW_ON_ERROR))); 98 | } 99 | 100 | /** 101 | * @throws \JsonException 102 | * @throws SignatureInvalidException 103 | * @throws SignatureNotFoundException 104 | * @throws ShopNotFoundException 105 | */ 106 | public function registerConfirm(RequestInterface $request): ResponseInterface 107 | { 108 | /** @var array $requestContent */ 109 | $requestContent = \json_decode($request->getBody()->getContents(), true, flags: JSON_THROW_ON_ERROR); 110 | 111 | if ( 112 | empty($requestContent['shopId']) || 113 | empty($requestContent['apiKey']) || 114 | empty($requestContent['secretKey']) || 115 | !is_string($requestContent['shopId']) || 116 | !is_string($requestContent['apiKey']) || 117 | !is_string($requestContent['secretKey']) 118 | ) { 119 | throw new MissingShopParameterException(); 120 | } 121 | 122 | $shop = $this->shopRepository->getShopFromId($requestContent['shopId']); 123 | 124 | if (!$shop) { 125 | throw new ShopNotFoundException($requestContent['shopId']); 126 | } 127 | 128 | $request->getBody()->rewind(); 129 | 130 | $this->requestVerifier->authenticatePostRequest($request, $shop); 131 | 132 | $this->eventDispatcher?->dispatch(new BeforeRegistrationCompletedEvent($shop, $request, $requestContent)); 133 | 134 | $this->shopRepository->updateShop( 135 | $shop->setShopApiCredentials($requestContent['apiKey'], $requestContent['secretKey']) 136 | ); 137 | 138 | $this->logger->info('Shop registration confirmed', [ 139 | 'shop-id' => $shop->getShopId(), 140 | 'shop-url' => $shop->getShopUrl(), 141 | ]); 142 | 143 | $this->eventDispatcher?->dispatch(new RegistrationCompletedEvent($request, $shop)); 144 | 145 | return (new Psr17Factory())->createResponse(204); 146 | } 147 | 148 | private function sanitizeShopUrl(string $shopUrl): string 149 | { 150 | $uri = new Uri($shopUrl); 151 | $path = preg_replace('#/{2,}#', '/', $uri->getPath()) ?? ''; 152 | $uri = $uri->withPath($path); 153 | 154 | return (string)$uri; 155 | } 156 | 157 | private function getSanitizedShop(ShopInterface $shop): ShopInterface 158 | { 159 | $sanitizedShop = clone $shop; 160 | 161 | return $sanitizedShop->setShopUrl($this->sanitizeShopUrl($shop->getShopUrl())); 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/Registration/ShopSecretGeneratorInterface.php: -------------------------------------------------------------------------------- 1 | 'openNewTab', 19 | 'payload' => [ 20 | 'redirectUrl' => $url 21 | ] 22 | ]); 23 | } 24 | 25 | /** 26 | * Reloads the Administration tab 27 | */ 28 | public static function reload(): ResponseInterface 29 | { 30 | return self::createResponse([ 31 | 'actionType' => 'reload', 32 | 'payload' => [] 33 | ]); 34 | } 35 | 36 | /** 37 | * @param 'small'|'medium'|'large'|'fullscreen' $size 38 | * @param bool $expand If true, the modal will be expanded to the full height of the screen 39 | */ 40 | public static function modal(string $url, string $size = 'medium', bool $expand = false): ResponseInterface 41 | { 42 | return self::createResponse([ 43 | 'actionType' => 'openModal', 44 | 'payload' => [ 45 | 'iframeUrl' => $url, 46 | 'size' => $size, 47 | 'expand' => $expand 48 | ] 49 | ]); 50 | } 51 | 52 | /** 53 | * @param 'success'|'error'|'info'|'warning' $type 54 | */ 55 | public static function notification(string $type, string $message): ResponseInterface 56 | { 57 | return self::createResponse([ 58 | 'actionType' => 'notification', 59 | 'payload' => [ 60 | 'message' => $message, 61 | 'status' => $type 62 | ] 63 | ]); 64 | } 65 | 66 | /** 67 | * @param array $data 68 | */ 69 | private static function createResponse(array $data): ResponseInterface 70 | { 71 | $psr = new Psr17Factory(); 72 | 73 | return $psr->createResponse(200) 74 | ->withHeader('Content-Type', 'application/json') 75 | ->withBody($psr->createStream(json_encode($data, JSON_THROW_ON_ERROR))); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Response/GatewayResponse.php: -------------------------------------------------------------------------------- 1 | $checkoutCommands 17 | */ 18 | public static function createCheckoutGatewayResponse(Collection $checkoutCommands): ResponseInterface 19 | { 20 | return self::createResponse($checkoutCommands->jsonSerialize()); 21 | } 22 | 23 | /** 24 | * @param Collection $contextCommands 25 | */ 26 | public static function createContextGatewayResponse(Collection $contextCommands): ResponseInterface 27 | { 28 | return self::createResponse($contextCommands->jsonSerialize()); 29 | } 30 | 31 | /** 32 | * @param array $data 33 | */ 34 | private static function createResponse(array $data): ResponseInterface 35 | { 36 | $psr = new Psr17Factory(); 37 | 38 | return $psr 39 | ->createResponse(200) 40 | ->withHeader('Content-Type', 'application/json') 41 | ->withBody($psr->createStream(\json_encode($data, \JSON_THROW_ON_ERROR))); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Response/InAppPurchasesResponse.php: -------------------------------------------------------------------------------- 1 | $purchases 15 | */ 16 | public static function filter(Collection $purchases): ResponseInterface 17 | { 18 | return self::createResponse(['purchases' => $purchases->all()]); 19 | } 20 | 21 | /** 22 | * @param array $data 23 | */ 24 | private static function createResponse(array $data): ResponseInterface 25 | { 26 | $psr = new Psr17Factory(); 27 | 28 | return $psr->createResponse(200) 29 | ->withHeader('Content-Type', 'application/json') 30 | ->withBody($psr->createStream(\json_encode($data, \JSON_THROW_ON_ERROR))); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Response/PaymentResponse.php: -------------------------------------------------------------------------------- 1 | $data - Data that will be saved on the order to identify the payment 26 | */ 27 | public static function validateSuccess(array $data): ResponseInterface 28 | { 29 | return self::createResponse(['preOrderPayment' => $data]); 30 | } 31 | 32 | public static function validationError(string $message): ResponseInterface 33 | { 34 | return self::createResponse(['message' => $message]); 35 | } 36 | 37 | public static function paid(): ResponseInterface 38 | { 39 | return self::createStatusResponse(self::ACTION_PAID); 40 | } 41 | 42 | public static function paidPartially(): ResponseInterface 43 | { 44 | return self::createStatusResponse(self::ACTION_PAID_PARTIALLY); 45 | } 46 | 47 | public static function cancelled(string $message = ''): ResponseInterface 48 | { 49 | return self::createStatusResponse(self::ACTION_CANCEL, $message); 50 | } 51 | 52 | public static function failed(string $message = ''): ResponseInterface 53 | { 54 | return self::createStatusResponse(self::ACTION_FAIL, $message); 55 | } 56 | 57 | public static function authorize(): ResponseInterface 58 | { 59 | return self::createStatusResponse(self::ACTION_AUTHORIZE); 60 | } 61 | 62 | public static function unconfirmed(): ResponseInterface 63 | { 64 | return self::createStatusResponse(self::ACTION_PROCESS_UNCONFIRMED); 65 | } 66 | 67 | public static function inProgress(): ResponseInterface 68 | { 69 | return self::createStatusResponse(self::ACTION_PROCESS); 70 | } 71 | 72 | public static function refunded(): ResponseInterface 73 | { 74 | return self::createStatusResponse(self::ACTION_REFUND); 75 | } 76 | 77 | public static function reminded(): ResponseInterface 78 | { 79 | return self::createStatusResponse(self::ACTION_REMIND); 80 | } 81 | 82 | public static function chargeback(): ResponseInterface 83 | { 84 | return self::createStatusResponse(self::ACTION_CHARGEBACK); 85 | } 86 | 87 | public static function reopen(): ResponseInterface 88 | { 89 | return self::createStatusResponse(self::ACTION_REOPEN); 90 | } 91 | 92 | public static function createStatusResponse(string $status, string $message = ''): ResponseInterface 93 | { 94 | return self::createResponse(array_filter(['status' => $status, 'message' => $message])); 95 | } 96 | 97 | public static function redirect(string $url): ResponseInterface 98 | { 99 | return self::createResponse(['redirectUrl' => $url]); 100 | } 101 | 102 | /** 103 | * @param array $data 104 | */ 105 | private static function createResponse(array $data): ResponseInterface 106 | { 107 | $psr = new Psr17Factory(); 108 | 109 | return $psr->createResponse(200) 110 | ->withHeader('Content-Type', 'application/json') 111 | ->withBody($psr->createStream(json_encode($data, JSON_THROW_ON_ERROR))); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/Response/RefundResponse.php: -------------------------------------------------------------------------------- 1 | $status, 'message' => ''])); 46 | } 47 | 48 | /** 49 | * @param array $data 50 | */ 51 | private static function createResponse(array $data): ResponseInterface 52 | { 53 | $psr = new Psr17Factory(); 54 | 55 | return $psr->createResponse(200) 56 | ->withHeader('Content-Type', 'application/json') 57 | ->withBody($psr->createStream(json_encode($data, JSON_THROW_ON_ERROR))); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Shop/ShopInterface.php: -------------------------------------------------------------------------------- 1 | $shopRepository 22 | */ 23 | public function __construct(private readonly ShopRepositoryInterface $shopRepository, private readonly RequestVerifier $requestVerifier = new RequestVerifier()) 24 | { 25 | } 26 | 27 | public function resolveShop(RequestInterface $request): ShopInterface 28 | { 29 | if ($request->getHeaderLine(self::SHOPWARE_APP_SHOP_ID) !== '') { 30 | return $this->resolveFromAppShopIdHeader($request); 31 | } 32 | 33 | if ($request->getHeaderLine('Content-Type') === 'application/json') { 34 | return $this->resolveFromSource($request); 35 | } 36 | 37 | return $this->resolveFromQueryString($request); 38 | } 39 | 40 | /** 41 | * @throws ShopNotFoundException 42 | * @throws SignatureInvalidException 43 | * @throws \JsonException 44 | */ 45 | private function resolveFromSource(RequestInterface $request): ShopInterface 46 | { 47 | $body = \json_decode($request->getBody()->getContents(), true, flags: JSON_THROW_ON_ERROR); 48 | $request->getBody()->rewind(); 49 | 50 | if (!is_array($body) || !isset($body['source']) || !isset($body['source']['shopId']) || !is_string($body['source']['shopId'])) { 51 | throw new MissingShopParameterException(); 52 | } 53 | 54 | $shop = $this->shopRepository->getShopFromId($body['source']['shopId']); 55 | 56 | if ($shop === null) { 57 | throw new ShopNotFoundException($body['source']['shopId']); 58 | } 59 | 60 | $this->requestVerifier->authenticatePostRequest($request, $shop); 61 | 62 | return $shop; 63 | } 64 | 65 | /** 66 | * @throws SignatureInvalidException 67 | * @throws MissingShopParameterException 68 | */ 69 | private function resolveFromQueryString(RequestInterface $request): ShopInterface 70 | { 71 | \parse_str($request->getUri()->getQuery(), $query); 72 | 73 | if (!isset($query['shop-id']) || !\is_string($query['shop-id'])) { 74 | throw new MissingShopParameterException(); 75 | } 76 | 77 | $shop = $this->shopRepository->getShopFromId($query['shop-id']); 78 | 79 | if ($shop === null) { 80 | throw new ShopNotFoundException($query['shop-id']); 81 | } 82 | 83 | $this->requestVerifier->authenticateGetRequest($request, $shop); 84 | 85 | return $shop; 86 | } 87 | 88 | private function resolveFromAppShopIdHeader(RequestInterface $request): ShopInterface 89 | { 90 | $shopId = $request->getHeaderLine(self::SHOPWARE_APP_SHOP_ID); 91 | $shop = $this->shopRepository->getShopFromId($shopId); 92 | 93 | if ($shop === null) { 94 | throw new ShopNotFoundException($shopId); 95 | } 96 | 97 | $this->requestVerifier->authenticateStorefrontRequest($request, $shop); 98 | 99 | return $shop; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/TaxProvider/CalculatedTax.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | public function jsonSerialize(): array 21 | { 22 | return \get_object_vars($this); 23 | } 24 | 25 | public function add(CalculatedTax $tax): self 26 | { 27 | return new self( 28 | $this->tax + $tax->tax, 29 | $this->taxRate, 30 | $this->price + $tax->price, 31 | implode(' + ', array_filter([$this->label, $tax->label])) ?: null, 32 | ); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/TaxProvider/TaxProviderResponseBuilder.php: -------------------------------------------------------------------------------- 1 | > 15 | */ 16 | protected Collection $lineItemTaxes; 17 | 18 | /** 19 | * @var Collection> 20 | */ 21 | protected Collection $deliveryTaxes; 22 | 23 | /** 24 | * @var Collection 25 | */ 26 | protected Collection $cartPriceTaxes; 27 | 28 | public function __construct() 29 | { 30 | $this->cartPriceTaxes = new Collection(); 31 | $this->deliveryTaxes = new Collection(); 32 | $this->lineItemTaxes = new Collection(); 33 | } 34 | 35 | public function addLineItemTax(string $uniqueIdentifier, CalculatedTax $tax): self 36 | { 37 | if (!$this->lineItemTaxes->has($uniqueIdentifier)) { 38 | $this->lineItemTaxes->set($uniqueIdentifier, new Collection()); 39 | } 40 | 41 | /** @phpstan-ignore-next-line is always set at this point */ 42 | $this->lineItemTaxes->get($uniqueIdentifier)->set((string) $tax->taxRate, $tax); 43 | return $this; 44 | } 45 | 46 | public function addDeliveryTax(string $uniqueIdentifier, CalculatedTax $tax): self 47 | { 48 | if (!$this->deliveryTaxes->has($uniqueIdentifier)) { 49 | $this->deliveryTaxes->set($uniqueIdentifier, new Collection()); 50 | } 51 | 52 | /** @phpstan-ignore-next-line is always set at this point */ 53 | $this->deliveryTaxes->get($uniqueIdentifier)->set((string) $tax->taxRate, $tax); 54 | return $this; 55 | } 56 | 57 | public function addCartTax(CalculatedTax $tax): self 58 | { 59 | if ($this->cartPriceTaxes->has((string) $tax->taxRate)) { 60 | /** @phpstan-ignore-next-line is always set at this point */ 61 | $tax = $this->cartPriceTaxes->get((string) $tax->taxRate)->add($tax); 62 | } 63 | 64 | $this->cartPriceTaxes->set((string) $tax->taxRate, $tax); 65 | return $this; 66 | } 67 | 68 | public function buildPayload(): string 69 | { 70 | return \json_encode(\get_object_vars($this), \JSON_THROW_ON_ERROR); 71 | } 72 | 73 | /** 74 | * @throws \JsonException 75 | */ 76 | public function build(): ResponseInterface 77 | { 78 | $psrFactory = new Psr17Factory(); 79 | 80 | return $psrFactory->createResponse(200) 81 | ->withHeader('Content-Type', 'application/json') 82 | ->withBody($psrFactory->createStream($this->buildPayload())); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Test/JWKSHelper.php: -------------------------------------------------------------------------------- 1 | $headers 27 | * @param array $claims 28 | */ 29 | public static function encodeIntoToken(array $headers, array $claims, string $signer = 'RS256'): Plain 30 | { 31 | $headers = \array_merge(['alg' => $signer, 'kid' => self::getStaticKid()], $headers); 32 | 33 | $b64Header = self::base64UrlEncode(\json_encode($headers, flags: \JSON_UNESCAPED_SLASHES | \JSON_THROW_ON_ERROR)); 34 | $b64Payload = self::base64UrlEncode(\json_encode($claims, flags: \JSON_UNESCAPED_SLASHES | \JSON_THROW_ON_ERROR)); 35 | 36 | $privateKey = InMemory::plainText(self::getPrivatePEM()); 37 | 38 | $signature = self::getSigner($signer)->sign($b64Header . '.' . $b64Payload, $privateKey); 39 | 40 | if (!$signature) { 41 | throw new \RuntimeException('Could not sign the token'); 42 | } 43 | 44 | /** @var non-empty-string $b64Signature */ 45 | $b64Signature = self::base64UrlEncode($signature); 46 | 47 | return new Plain( 48 | new DataSet($headers, $b64Header), 49 | new DataSet($claims, $b64Payload), 50 | new Signature($signature, $b64Signature) 51 | ); 52 | } 53 | 54 | public static function getStaticKid(): string 55 | { 56 | $jwks = self::getPublicJWKS(); 57 | 58 | /** @var array{keys: array{0: array{kid?: non-empty-string}}} $decoded */ 59 | $decoded = \json_decode($jwks, true, flags: \JSON_THROW_ON_ERROR); 60 | 61 | if (!isset($decoded['keys'][0]['kid'])) { 62 | throw new \RuntimeException('Could not find the kid in the JWKS'); 63 | } 64 | 65 | return $decoded['keys'][0]['kid']; 66 | } 67 | 68 | /** 69 | * @return non-empty-string 70 | */ 71 | public static function getPublicJWKS(): string 72 | { 73 | $jwks = \file_get_contents(__DIR__ . '/_fixtures/jwks.json'); 74 | 75 | if (!$jwks) { 76 | throw new \RuntimeException('Could not load the JWKS'); 77 | } 78 | 79 | return $jwks; 80 | } 81 | 82 | /** 83 | * @return non-empty-string 84 | */ 85 | public static function getPrivatePEM(): string 86 | { 87 | $key = \file_get_contents(__DIR__ . '/_fixtures/jwks_private.pem'); 88 | 89 | if (!$key) { 90 | throw new \RuntimeException('Could not load the private PEM'); 91 | } 92 | 93 | return $key; 94 | } 95 | 96 | private static function base64UrlEncode(string $data): string 97 | { 98 | return \rtrim(\strtr(\base64_encode($data), '+/', '-_'), '='); 99 | } 100 | 101 | private static function getSigner(string $alg): Rsa 102 | { 103 | return match ($alg) { 104 | 'RS256' => new Sha256(), 105 | 'RS384' => new Sha384(), 106 | 'RS512' => new Sha512(), 107 | default => throw new CannotSignPayload(\sprintf('Unsupported algorithm: "%s"', $alg)), 108 | }; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/Test/MockClient.php: -------------------------------------------------------------------------------- 1 | $responses 15 | */ 16 | public function __construct(private array $responses) 17 | { 18 | } 19 | 20 | public function sendRequest(RequestInterface $request): ResponseInterface 21 | { 22 | $response = array_shift($this->responses); 23 | 24 | if ($response === null) { 25 | throw new \RuntimeException('No more responses available'); 26 | } 27 | 28 | return $response; 29 | } 30 | 31 | public function isEmpty(): bool 32 | { 33 | return count($this->responses) === 0; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Test/MockShop.php: -------------------------------------------------------------------------------- 1 | shopId; 24 | } 25 | 26 | public function getShopUrl(): string 27 | { 28 | return $this->shopUrl; 29 | } 30 | 31 | public function getShopSecret(): string 32 | { 33 | return $this->shopSecret; 34 | } 35 | 36 | public function getShopClientId(): ?string 37 | { 38 | return $this->clientId; 39 | } 40 | 41 | public function getShopClientSecret(): ?string 42 | { 43 | return $this->clientSecret; 44 | } 45 | 46 | public function isShopActive(): bool 47 | { 48 | return $this->shopActive; 49 | } 50 | 51 | public function setShopApiCredentials(string $clientId, string $clientSecret): ShopInterface 52 | { 53 | $this->clientId = $clientId; 54 | $this->clientSecret = $clientSecret; 55 | 56 | return $this; 57 | } 58 | 59 | public function setShopUrl(string $url): ShopInterface 60 | { 61 | $this->shopUrl = $url; 62 | 63 | return $this; 64 | } 65 | 66 | public function setShopActive(bool $active): ShopInterface 67 | { 68 | $this->shopActive = $active; 69 | 70 | return $this; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Test/MockShopRepository.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | class MockShopRepository implements ShopRepositoryInterface 14 | { 15 | /** 16 | * @var array 17 | */ 18 | public array $shops = []; 19 | 20 | public function createShopStruct(string $shopId, string $shopUrl, string $shopSecret): ShopInterface 21 | { 22 | return new MockShop($shopId, $shopUrl, $shopSecret); 23 | } 24 | 25 | public function createShop(ShopInterface $shop): void 26 | { 27 | $this->shops[$shop->getShopId()] = $shop; 28 | } 29 | 30 | public function getShopFromId(string $shopId): ShopInterface|null 31 | { 32 | return $this->shops[$shopId] ?? null; 33 | } 34 | 35 | public function updateShop(ShopInterface $shop): void 36 | { 37 | $this->shops[$shop->getShopId()] = $shop; 38 | } 39 | 40 | public function deleteShop(string $shopId): void 41 | { 42 | unset($this->shops[$shopId]); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Test/_fixtures/jwks.json: -------------------------------------------------------------------------------- 1 | { 2 | "keys": [ 3 | { 4 | "kty": "RSA", 5 | "n": "xHenasOsOl-Vv2BmpayS1R8l5L-JN99FwaRRKXFssGTjJDwbYdbe3CqTSKqtOfdqZLzE6-bN2-Q1xqZZsgs0_zHNx7EROXNG_uQs1uuGkS6bgGhnq_2d7wzFvCsyI00CDXZxRlGjKAEhvcXormomF1jpUW08Y5tPeUvMSdEZbZxW1ydir-UrMm1RUSgJgSP-sUqLG7kTIJ6SG7cLtF8c8cHcVXFljMyiYLQHYOECj1oklwvfrfaoT3OKdKGumi39rDthXtFa0Aq1OS_P9qfZJ-yXiQlpf2RxRr3Q5EQJ8E9iqrlOndbkSq7eXne2DvvgsiNdyzRWFvxWSPSd9GZXkw", 6 | "e": "AQAB", 7 | "kid": "-1xljHNcPM59Qx9OcULA9LS219bsmKCZueVXhdF0N0k", 8 | "use": "sig", 9 | "alg": "RS256" 10 | } 11 | ] 12 | } -------------------------------------------------------------------------------- /src/Test/_fixtures/jwks_private.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDEd6dqw6w6X5W/ 3 | YGalrJLVHyXkv4k330XBpFEpcWywZOMkPBth1t7cKpNIqq0592pkvMTr5s3b5DXG 4 | plmyCzT/Mc3HsRE5c0b+5CzW64aRLpuAaGer/Z3vDMW8KzIjTQINdnFGUaMoASG9 5 | xeiuaiYXWOlRbTxjm095S8xJ0RltnFbXJ2Kv5SsybVFRKAmBI/6xSosbuRMgnpIb 6 | twu0XxzxwdxVcWWMzKJgtAdg4QKPWiSXC9+t9qhPc4p0oa6aLf2sO2Fe0VrQCrU5 7 | L8/2p9kn7JeJCWl/ZHFGvdDkRAnwT2KquU6d1uRKrt5ed7YO++CyI13LNFYW/FZI 8 | 9J30ZleTAgMBAAECggEBAICWh/763ui90vcVE1nwH5JZ9qVHL3pgKfnZoD1tvWOe 9 | RIRcytc4mVikjQ2MmCBObVZNrt1vjOEU9pV0H1TGvQMiB7thixJ8/sSpdyebs9Gm 10 | HaTnsKMDNbFWPL0x0HkGWAeTtAQAPrdaNklNFUPCufeh++ONiW7wIg4TrDvMHcZ8 11 | 2u9l5VK7PG1okFeWPjKT7CKCwJD8L1hQoR4r/5LClmtmriFX+Bunn3qDTo6Komrm 12 | 2Vo5XLBNCYa8fqHUo4TklF89DiqdkrtXxq2aQBRazgDilA5f76USC1qwkHg95rGt 13 | 3dSyImnHdTZvJPBfjAT+d/KKgLo2wCzLj/ocrEdOTyECgYEA/qynhWN1fNkufJCC 14 | hd6/w61QnzelUF+LT8aj287EN2+2IvNpxWXQbXBRvQD89yttB3gTHQ7pTFzk/Nhu 15 | 11S7keEOCZ6TZCB3IN5DlqeS8xH7l95XsVeJTmFskC8R3RWf+oVvCczqUdEh/pJ2 16 | +sSuYrxABd0qPftDSzw5S+BoLAsCgYEAxX1wyNLrnBsfWzQIQN6ApZfTwRzIVSnB 17 | 86SNg0rAz7lgWyUYB4wyDpxDcMNQXJKT+65DknjMMwr0aQXsdLD7PZCoB1ZeIthW 18 | FUKUMd5xySRMI7Jz8sgCMI7+a03TFZGefvDajKZfIb+B5X5RXyBFj4ZdE0RzPVc0 19 | F0ZJANzqL5kCgYBRlvbEYSzOprWh6pomUUqWYfwmRimMKk/zauwsC34JVJzBbcJB 20 | H3E63nURXDOu7BauAeX9tT1A3bHu31gDXH4LKlLY+hi2R0BjI1H7/guyG9zCttTZ 21 | V/YBSm0BdFMAxWDPXdhJB4E6XQnLCRldFY3Yy/6m4kgaO1k/zTd9+5LkKQKBgQCZ 22 | jrX5kPceRICXG9gnCFsjZvCBwW2LupJSbeqS4bcWTV+8vxT2GW56qcpOja+Yq2hh 23 | U9i6tS6hqaztVGvNOCfCcQ4V1nQPyAL3OPOd6wayjYSeZQ1/A0GgnC77JGIy7S0+ 24 | KuzkXCCwTwYjeZqvypoLxT/t0VWBpqKcTkxJXXzCUQKBgQD3yPi223Z2wC08AXA2 25 | MgdYBcCEEa3CGKQ5r286VdLYGPvugYyQk7RHD0IULHUldeO+HdWEad8/LX3jGZ11 26 | m1H8hMeEunQ4sEVPwFRObpqYTsM9atXJhGD6EBTfJH8E3v1tljCftHnM6QCh6aR3 27 | zy5EEHMkAAgTvQIuPqKSbRbrVA== 28 | -----END PRIVATE KEY----- --------------------------------------------------------------------------------