├── phpstan.neon ├── .gitignore ├── src ├── RequestQueryParams │ ├── GetRefundHistoryQueryParams.php │ ├── GetAllSubscriptionStatusesQueryParams.php │ ├── GetNotificationHistoryQueryParams.php │ ├── PageableQueryParams.php │ ├── AbstractRequestQueryParams.php │ └── GetTransactionHistoryQueryParams.php ├── Exception │ ├── AppStoreServerAPIException.php │ ├── AppStoreServerNotificationException.php │ ├── MalformedJWTException.php │ ├── MalformedResponseException.php │ ├── UnimplementedContentTypeException.php │ ├── HTTPRequestAborted.php │ ├── JWTCreationException.php │ ├── InvalidImplementationException.php │ ├── WrongEnvironmentException.php │ └── HTTPRequestFailed.php ├── Request │ ├── LookUpOrderIdRequest.php │ ├── GetRefundHistoryRequest.php │ ├── GetTransactionHistoryRequest.php │ ├── GetNotificationHistoryRequest.php │ ├── GetTransactionInfoRequest.php │ ├── RequestTestNotificationRequest.php │ ├── GetAllSubscriptionStatusesRequest.php │ ├── GetTestNotificationStatusRequest.php │ ├── SendConsumptionInformationRequest.php │ ├── MassExtendSubscriptionRenewalDateRequest.php │ ├── ExtendSubscriptionRenewalDateRequest.php │ ├── GetStatusOfSubscriptionRenewalDateExtensionsRequest.php │ ├── AbstractRequest.php │ └── AbstractRequestParamsBag.php ├── Environment.php ├── Key.php ├── Response │ ├── MassExtendRenewalDateResponse.php │ ├── SendTestNotificationResponse.php │ ├── CheckTestNotificationResponse.php │ ├── TransactionInfoResponse.php │ ├── AbstractResponse.php │ ├── NotificationHistoryResponse.php │ ├── RefundHistoryResponse.php │ ├── OrderLookupResponse.php │ ├── ExtendRenewalDateResponse.php │ ├── PageableResponse.php │ ├── MassExtendRenewalDateStatusResponse.php │ ├── StatusResponse.php │ └── HistoryResponse.php ├── RequestBody │ ├── AbstractRequestBody.php │ ├── ExtendRenewalDateRequestBody.php │ ├── MassExtendRenewalDateRequestBody.php │ ├── NotificationHistoryRequestBody.php │ └── ConsumptionRequestBody.php ├── Payload.php ├── SubscriptionGroupIdentifierItem.php ├── Util │ ├── Helper.php │ ├── ASN1SequenceOfInteger.php │ └── JWT.php ├── HTTPRequest.php ├── NotificationHistoryResponseItem.php ├── LastTransactionsItem.php ├── SendAttemptItem.php ├── AppMetadata.php ├── AppStoreServerAPIInterface.php ├── RenewalInfo.php ├── AppStoreServerAPI.php ├── TransactionInfo.php └── ResponseBodyV2.php ├── .php-cs-fixer.dist.php ├── composer.json ├── examples ├── getNotificationHistory.php ├── extendSubscriptionRenewalDate.php ├── massExtendSubscriptionRenewalDate.php ├── requestAndCheckTestNotification.php ├── sendConsumptionInformation.php ├── getStatusOfSubscriptionRenewalDateExtensionsRequest.php ├── client.php ├── getTransactionInfo.php ├── getRefundHistory.php ├── lookUpOrderId.php ├── getTransactionHistory.php ├── getAllSubscriptionStatuses.php └── serverNotificationV2.php ├── LICENSE ├── CHANGELOG.md └── README.md /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | paths: 3 | - src 4 | level: 6 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .php-cs-fixer.cache 3 | examples/credentials.json 4 | examples/notification.json 5 | vendor/ 6 | -------------------------------------------------------------------------------- /src/RequestQueryParams/GetRefundHistoryQueryParams.php: -------------------------------------------------------------------------------- 1 | in(__DIR__); 4 | 5 | return (new PhpCsFixer\Config())->setRules([ 6 | '@PSR1' => true, 7 | '@PSR2' => true, 8 | 'blank_line_after_opening_tag' => false, 9 | ])->setFinder($finder); 10 | -------------------------------------------------------------------------------- /src/Exception/MalformedJWTException.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | protected array $status = []; 15 | } 16 | -------------------------------------------------------------------------------- /src/Request/GetStatusOfSubscriptionRenewalDateExtensionsRequest.php: -------------------------------------------------------------------------------- 1 | keyId = $keyId; 14 | $this->key = $key; 15 | } 16 | 17 | public function getKeyId(): string 18 | { 19 | return $this->keyId; 20 | } 21 | 22 | public function getKey(): string 23 | { 24 | return $this->key; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Exception/WrongEnvironmentException.php: -------------------------------------------------------------------------------- 1 | requestIdentifier; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/RequestQueryParams/GetNotificationHistoryQueryParams.php: -------------------------------------------------------------------------------- 1 | revision = $revision; 21 | return $newQueryParams; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Exception/HTTPRequestFailed.php: -------------------------------------------------------------------------------- 1 | responseText = $message; 14 | 15 | if ($code === 0) { 16 | parent::__construct("HTTP request [$method $url] failed: $message"); 17 | } else { 18 | parent::__construct("HTTP request [$method $url] failed with status code $code. Response text is: $message", $code); 19 | } 20 | } 21 | 22 | /** 23 | * Get the response text 24 | * 25 | * @return string 26 | */ 27 | public function getResponseText(): string 28 | { 29 | return $this->responseText; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Response/SendTestNotificationResponse.php: -------------------------------------------------------------------------------- 1 | testNotificationToken; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/RequestBody/AbstractRequestBody.php: -------------------------------------------------------------------------------- 1 | getContentType()) { 24 | case self::CONTENT_TYPE__JSON: 25 | return json_encode($this->collectProps()); 26 | 27 | default: 28 | throw new UnimplementedContentTypeException($this->getContentType()); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/RequestQueryParams/AbstractRequestQueryParams.php: -------------------------------------------------------------------------------- 1 | collectProps() as $propName => $value) { 20 | if (is_array($value)) { 21 | $queryStringParams = array_merge( 22 | $queryStringParams, 23 | array_map(fn ($v) => $propName . '=' . rawurlencode($v), $value) 24 | ); 25 | } else { 26 | $queryStringParams[] = $propName . '=' . rawurlencode($value); 27 | } 28 | } 29 | 30 | return join('&', $queryStringParams); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Response/CheckTestNotificationResponse.php: -------------------------------------------------------------------------------- 1 | notificationHistoryResponseItem = NotificationHistoryResponseItem::createFromRawItem($properties); 19 | $properties = []; 20 | parent::__construct($properties, $originalRequest); 21 | } 22 | 23 | public function getNotificationHistoryResponseItem(): NotificationHistoryResponseItem 24 | { 25 | return $this->notificationHistoryResponseItem; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "readdle/app-store-server-api", 3 | "description": "Pure-PHP library that allows managing customer transactions using the App Store Server API and handling server-to-server notifications using the App Store Server Notifications V2", 4 | "type": "library", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Pavlo Kotets", 9 | "email": "pkotets@readdle.com", 10 | "homepage": "https://github.com/pkotets" 11 | } 12 | ], 13 | "keywords": [ 14 | "app store", 15 | "server api", 16 | "server notifications", 17 | "itunes", 18 | "apple", 19 | "in app", 20 | "purchase", 21 | "parser", 22 | "server-to-server", 23 | "php" 24 | ], 25 | "homepage": "https://github.com/readdle/app-store-server-api", 26 | "version": "3.6.3", 27 | "php": ">=7.4", 28 | "autoload": { 29 | "psr-4": { 30 | "Readdle\\AppStoreServerAPI\\": "src/" 31 | } 32 | }, 33 | "require": { 34 | "ext-json": "*", 35 | "ext-openssl": "*" 36 | }, 37 | "require-dev": { 38 | "friendsofphp/php-cs-fixer": "^3.23", 39 | "phpstan/phpstan": "^1.10" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /examples/getNotificationHistory.php: -------------------------------------------------------------------------------- 1 | $api, 'credentials' => $credentials] = require 'client.php'; 5 | 6 | use Readdle\AppStoreServerAPI\Exception\AppStoreServerAPIException; 7 | 8 | try { 9 | $notificationHistoryResponse = $api->getNotificationHistory(['startDate' => (time() - 60 * 60) * 1000]); 10 | $notificationHistoryResponseItems = $notificationHistoryResponse->getNotificationHistory(); 11 | } catch (AppStoreServerAPIException $e) { 12 | exit($e->getMessage()); 13 | } 14 | 15 | foreach ($notificationHistoryResponseItems as $notificationHistoryResponseItem) { 16 | echo "\nSend attempts\n"; 17 | 18 | foreach ($notificationHistoryResponseItem->getSendAttempts() as $sendAttempt) { 19 | echo "Attempt Date: {$sendAttempt->getAttemptDate()}\n"; 20 | echo "Attempt Result: {$sendAttempt->getSendAttemptResult()}\n\n"; 21 | } 22 | 23 | echo "Signed Payload: {$notificationHistoryResponseItem->getSignedPayload()}\n"; 24 | echo 'ResponseBodyV2 as JSON: ' . json_encode($notificationHistoryResponseItem->getResponseBodyV2()) . "\n"; 25 | 26 | } 27 | -------------------------------------------------------------------------------- /examples/extendSubscriptionRenewalDate.php: -------------------------------------------------------------------------------- 1 | $api, 'credentials' => $credentials] = require 'client.php'; 5 | 6 | use Readdle\AppStoreServerAPI\Exception\AppStoreServerAPIException; 7 | use Readdle\AppStoreServerAPI\RequestBody\ExtendRenewalDateRequestBody; 8 | 9 | try { 10 | $extendRenewalDateResponse = $api->extendSubscriptionRenewalDate( 11 | $credentials['transactionId'], 12 | [ 13 | 'extendByDays' => 1, 14 | 'extendReasonCode' => ExtendRenewalDateRequestBody::EXTEND_REASON_CODE__UNDECLARED, 15 | 'requestIdentifier' => 'test', 16 | ] 17 | ); 18 | } catch (AppStoreServerAPIException $e) { 19 | exit($e->getMessage()); 20 | } catch (Exception $e) { 21 | exit("Wrong request body: {$e->getMessage()}"); 22 | } 23 | 24 | echo "Extend renewal date\n\n"; 25 | echo "Effective Date: {$extendRenewalDateResponse->getEffectiveDate()}\n"; 26 | echo "Original Transaction ID: {$extendRenewalDateResponse->getOriginalTransactionId()}\n"; 27 | echo "Web Order Line Item ID: {$extendRenewalDateResponse->getWebOrderLineItemId()}\n"; 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Readdle Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/massExtendSubscriptionRenewalDate.php: -------------------------------------------------------------------------------- 1 | $api, 'credentials' => $credentials] = require 'client.php'; 5 | 6 | if (empty($credentials['productId'])) { 7 | exit('You have to specify productId in order to request mass subscription renewal date extension'); 8 | } 9 | 10 | use Readdle\AppStoreServerAPI\Exception\AppStoreServerAPIException; 11 | use Readdle\AppStoreServerAPI\RequestBody\ExtendRenewalDateRequestBody; 12 | 13 | try { 14 | $massExtendRenewalDateResponse = $api->massExtendSubscriptionRenewalDate([ 15 | 'extendByDays' => 1, 16 | 'extendReasonCode' => ExtendRenewalDateRequestBody::EXTEND_REASON_CODE__UNDECLARED, 17 | 'productId' => $credentials['productId'], 18 | 'requestIdentifier' => vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4)), 19 | 'storefrontCountryCodes' => ['POL'] 20 | ]); 21 | } catch (AppStoreServerAPIException $e) { 22 | exit($e->getMessage()); 23 | } catch (Exception $e) { 24 | exit("Wrong request body: {$e->getMessage()}"); 25 | } 26 | 27 | echo "Mass extend renewal date\n\n"; 28 | echo "Request Identifier: {$massExtendRenewalDateResponse->getRequestIdentifier()}\n"; 29 | -------------------------------------------------------------------------------- /examples/requestAndCheckTestNotification.php: -------------------------------------------------------------------------------- 1 | $api, 'credentials' => $credentials] = require 'client.php'; 5 | 6 | use Readdle\AppStoreServerAPI\Exception\AppStoreServerAPIException; 7 | 8 | try { 9 | $testNotification = $api->requestTestNotification(); 10 | } catch (AppStoreServerAPIException $e) { 11 | exit($e->getMessage()); 12 | } 13 | 14 | echo "Test notification token: {$testNotification->getTestNotificationToken()}\n"; 15 | 16 | try { 17 | $testNotificationStatus = $api->getTestNotificationStatus($testNotification->getTestNotificationToken()); 18 | } catch (AppStoreServerAPIException $e) { 19 | exit($e->getMessage()); 20 | } 21 | 22 | $notificationHistoryResponseItem = $testNotificationStatus->getNotificationHistoryResponseItem(); 23 | 24 | echo "\nSend attempts\n"; 25 | 26 | foreach ($notificationHistoryResponseItem->getSendAttempts() as $sendAttempt) { 27 | echo "Attempt Date: {$sendAttempt->getAttemptDate()}\n"; 28 | echo "Attempt Result: {$sendAttempt->getSendAttemptResult()}\n"; 29 | } 30 | 31 | echo "Signed Payload: {$notificationHistoryResponseItem->getSignedPayload()}\n"; 32 | echo 'ResponseBodyV2 as JSON: ' . json_encode($notificationHistoryResponseItem->getResponseBodyV2()) . "\n"; 33 | -------------------------------------------------------------------------------- /examples/sendConsumptionInformation.php: -------------------------------------------------------------------------------- 1 | $api, 'credentials' => $credentials] = require 'client.php'; 5 | 6 | use Readdle\AppStoreServerAPI\Exception\AppStoreServerAPIException; 7 | use Readdle\AppStoreServerAPI\RequestBody\ConsumptionRequestBody; 8 | 9 | try { 10 | $api->sendConsumptionInformation($credentials['transactionId'], [ 11 | 'accountTenure' => ConsumptionRequestBody::ACCOUNT_TENURE__UNDECLARED, 12 | 'appAccountToken' => '', 13 | 'consumptionStatus' => ConsumptionRequestBody::CONSUMPTION_STATUS__UNDECLARED, 14 | 'customerConsented' => true, 15 | 'deliveryStatus' => ConsumptionRequestBody::DELIVERY_STATUS__DELIVERED, 16 | 'lifetimeDollarsPurchased' => ConsumptionRequestBody::LIFETIME_DOLLARS_PURCHASED__UNDECLARED, 17 | 'lifetimeDollarsRefunded' => ConsumptionRequestBody::LIFETIME_DOLLARS_REFUNDED__UNDECLARED, 18 | 'platform' => ConsumptionRequestBody::PLATFORM__UNDECLARED, 19 | 'playTime' => ConsumptionRequestBody::PLAY_TIME__UNDECLARED, 20 | 'sampleContentProvided' => true, 21 | 'userStatus' => ConsumptionRequestBody::USER_STATUS__UNDECLARED, 22 | ]); 23 | } catch (AppStoreServerAPIException $e) { 24 | exit($e->getMessage()); 25 | } 26 | 27 | echo "Consumption information sent\n"; 28 | -------------------------------------------------------------------------------- /src/Response/TransactionInfoResponse.php: -------------------------------------------------------------------------------- 1 | transactionInfo = TransactionInfo::createFromRawTransactionInfo(JWT::parse($properties['signedTransactionInfo'])); 27 | unset($properties['signedTransactionInfo']); 28 | parent::__construct($properties, $originalRequest); 29 | } 30 | 31 | /** 32 | * Returns a customer's in-app purchase transaction. 33 | */ 34 | public function getTransactionInfo(): TransactionInfo 35 | { 36 | return $this->transactionInfo; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /examples/getStatusOfSubscriptionRenewalDateExtensionsRequest.php: -------------------------------------------------------------------------------- 1 | $api, 'credentials' => $credentials] = require 'client.php'; 5 | 6 | if (empty($credentials['productId'])) { 7 | exit('You have to specify productId in order to get status of subscription renewal date extension request'); 8 | } 9 | 10 | if (empty($credentials['requestIdentifier'])) { 11 | exit('You have to specify requestIdentifier in order to get status of subscription renewal date extension request'); 12 | } 13 | 14 | use Readdle\AppStoreServerAPI\Exception\AppStoreServerAPIException; 15 | 16 | try { 17 | $massExtendRenewalDateStatusResponse = $api->getStatusOfSubscriptionRenewalDateExtensionsRequest( 18 | $credentials['productId'], 19 | $credentials['requestIdentifier'], 20 | ); 21 | } catch (AppStoreServerAPIException $e) { 22 | exit($e->getMessage()); 23 | } 24 | 25 | echo "Mass extend renewal date status\n\n"; 26 | 27 | echo "Complete Date: {$massExtendRenewalDateStatusResponse->getCompleteDate()}\n"; 28 | echo 'Is Complete: ' . ($massExtendRenewalDateStatusResponse->isComplete() ? 'yes' : 'no') . "\n"; 29 | echo "Request Identifier: {$massExtendRenewalDateStatusResponse->getRequestIdentifier()}\n"; 30 | echo "Failed Count: {$massExtendRenewalDateStatusResponse->getFailedCount()}\n"; 31 | echo "Succeeded Count: {$massExtendRenewalDateStatusResponse->getSucceededCount()}\n"; 32 | -------------------------------------------------------------------------------- /src/Payload.php: -------------------------------------------------------------------------------- 1 | issuerId = $issuerId; 35 | $this->bundleId = $bundleId; 36 | 37 | $this->ttl = $ttl === 0 ? self::MAX_TTL : min($ttl, self::MAX_TTL); 38 | } 39 | 40 | /** 41 | * @return array 42 | */ 43 | public function toArray(): array 44 | { 45 | $time = time(); 46 | 47 | return [ 48 | 'iss' => $this->issuerId, 49 | 'iat' => $time, 50 | 'exp' => $time + $this->ttl, 51 | 'aud' => $this->audience, 52 | 'bid' => $this->bundleId, 53 | ]; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Response/AbstractResponse.php: -------------------------------------------------------------------------------- 1 | $properties 22 | */ 23 | protected function __construct(array $properties, AbstractRequest $originalRequest) 24 | { 25 | $this->originalRequest = $originalRequest; 26 | 27 | foreach ($properties as $key => $value) { 28 | if (property_exists($this, $key)) { 29 | $this->$key = $value; 30 | } 31 | } 32 | } 33 | 34 | /** 35 | * @return AbstractResponse|PageableResponse 36 | * @throws MalformedResponseException 37 | */ 38 | public static function createFromString(string $string, AbstractRequest $originalRequest): AbstractResponse 39 | { 40 | $array = json_decode($string, true); 41 | 42 | if (json_last_error() !== JSON_ERROR_NONE) { 43 | throw new MalformedResponseException('Invalid JSON'); 44 | } 45 | 46 | return new static($array, $originalRequest); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /examples/client.php: -------------------------------------------------------------------------------- 1 | getMessage()); 47 | } 48 | 49 | return [ 50 | 'api' => $api, 51 | 'credentials' => $credentials, 52 | ]; 53 | -------------------------------------------------------------------------------- /src/Response/NotificationHistoryResponse.php: -------------------------------------------------------------------------------- 1 | items[] = NotificationHistoryResponseItem::createFromRawItem($rawResponseItem); 23 | } 24 | 25 | if (!empty($properties['paginationToken'])) { 26 | $properties['revision'] = $properties['paginationToken']; 27 | } 28 | 29 | unset($properties['notificationHistory'], $properties['paginationToken']); 30 | parent::__construct($properties, $originalRequest); 31 | } 32 | 33 | /** 34 | * Returns a Generator which iterates over an array of App Store Server Notifications history records. 35 | * 36 | * @return Generator 37 | * 38 | * @throws MalformedResponseException 39 | * @throws HTTPRequestAborted 40 | * @throws HTTPRequestFailed 41 | * @throws UnimplementedContentTypeException 42 | */ 43 | public function getNotificationHistory(): Generator 44 | { 45 | return $this->getItems(); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Response/RefundHistoryResponse.php: -------------------------------------------------------------------------------- 1 | items[] = TransactionInfo::createFromRawTransactionInfo(JWT::parse($signedTransaction)); 28 | } 29 | 30 | unset($properties['signedTransactions']); 31 | parent::__construct($properties, $originalRequest); 32 | } 33 | 34 | /** 35 | * Returns a Generator which iterates over a list of refunded transactions. 36 | * The transactions are sorted in ascending order by revocationDate. 37 | * 38 | * @return Generator 39 | * 40 | * @throws HTTPRequestAborted 41 | * @throws HTTPRequestFailed 42 | * @throws MalformedResponseException 43 | * @throws UnimplementedContentTypeException 44 | */ 45 | public function getTransactions(): Generator 46 | { 47 | return $this->getItems(); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /examples/getTransactionInfo.php: -------------------------------------------------------------------------------- 1 | $api, 'credentials' => $credentials] = require 'client.php'; 5 | 6 | use Readdle\AppStoreServerAPI\Exception\AppStoreServerAPIException; 7 | 8 | try { 9 | $transactionInfoResponse = $api->getTransactionInfo($credentials['transactionId']); 10 | } catch (AppStoreServerAPIException $e) { 11 | exit($e->getMessage()); 12 | } 13 | 14 | $transactionInfo = $transactionInfoResponse->getTransactionInfo(); 15 | 16 | echo "App Account Token: {$transactionInfo->getAppAccountToken()}\n"; 17 | echo "Bundle ID: {$transactionInfo->getBundleId()}\n"; 18 | echo "Environment: {$transactionInfo->getEnvironment()}\n"; 19 | echo "Expires Date: {$transactionInfo->getExpiresDate()}\n"; 20 | echo "In-App Ownership Type: {$transactionInfo->getInAppOwnershipType()}\n"; 21 | echo "Is Upgraded: {$transactionInfo->getIsUpgraded()}\n"; 22 | echo "Offer Identifier: {$transactionInfo->getOfferIdentifier()}\n"; 23 | echo "Offer Type: {$transactionInfo->getOfferType()}\n"; 24 | echo "Original Purchase Date: {$transactionInfo->getOriginalPurchaseDate()}\n"; 25 | echo "Original Transaction ID: {$transactionInfo->getOriginalTransactionId()}\n"; 26 | echo "Product ID: {$transactionInfo->getProductId()}\n"; 27 | echo "Purchase Date: {$transactionInfo->getPurchaseDate()}\n"; 28 | echo "Quantity: {$transactionInfo->getQuantity()}\n"; 29 | echo "Revocation Date: {$transactionInfo->getRevocationDate()}\n"; 30 | echo "Revocation Reason: {$transactionInfo->getRevocationReason()}\n"; 31 | echo "Signed Date: {$transactionInfo->getSignedDate()}\n"; 32 | echo "Subscription Group Identifier: {$transactionInfo->getSubscriptionGroupIdentifier()}\n"; 33 | echo "Transaction ID: {$transactionInfo->getTransactionId()}\n"; 34 | echo "Type: {$transactionInfo->getType()}\n"; 35 | echo "Web Order Line Item ID: {$transactionInfo->getWebOrderLineItemId()}\n"; 36 | 37 | echo "\nAs JSON: " . json_encode($transactionInfo) . "\n\n"; 38 | -------------------------------------------------------------------------------- /src/RequestBody/ExtendRenewalDateRequestBody.php: -------------------------------------------------------------------------------- 1 | extendByDays < 1 || $this->extendByDays > 90) { 58 | throw new Exception('"extendByDays" should be numeric value in range from 1 to 90'); 59 | } 60 | 61 | if (strlen($this->requestIdentifier) > 128) { 62 | throw new Exception('"requestIdentifier" should be string value with length from 1 to 128'); 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Response/OrderLookupResponse.php: -------------------------------------------------------------------------------- 1 | 28 | */ 29 | protected array $transactions = []; 30 | 31 | /** 32 | * @throws MalformedJWTException 33 | */ 34 | protected function __construct(array $properties, AbstractRequest $originalRequest) 35 | { 36 | foreach ($properties['signedTransactions'] ?? [] as $signedTransaction) { 37 | $this->transactions[] = TransactionInfo::createFromRawTransactionInfo(JWT::parse($signedTransaction)); 38 | } 39 | 40 | unset($properties['signedTransactions']); 41 | parent::__construct($properties, $originalRequest); 42 | } 43 | 44 | /** 45 | * Returns the status that indicates whether the order ID is valid. 46 | * 47 | * @return self::STATUS__* 48 | */ 49 | public function getStatus(): int 50 | { 51 | /** @phpstan-ignore-next-line */ 52 | return $this->status; 53 | } 54 | 55 | /** 56 | * Returns an array of in-app purchase transactions that are part of order. 57 | * 58 | * @return array 59 | */ 60 | public function getTransactions(): array 61 | { 62 | return $this->transactions; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /examples/getRefundHistory.php: -------------------------------------------------------------------------------- 1 | $api, 'credentials' => $credentials] = require 'client.php'; 5 | 6 | use Readdle\AppStoreServerAPI\Exception\AppStoreServerAPIException; 7 | 8 | try { 9 | $refundHistory = $api->getRefundHistory($credentials['transactionId']); 10 | $transactions = $refundHistory->getTransactions(); 11 | } catch (AppStoreServerAPIException $e) { 12 | exit($e->getMessage()); 13 | } 14 | 15 | echo "Refund history\n\n"; 16 | 17 | foreach ($transactions as $i => $transactionInfo) { 18 | echo "Transaction Info #$i\n"; 19 | echo "App Account Token: {$transactionInfo->getAppAccountToken()}\n"; 20 | echo "Bundle ID: {$transactionInfo->getBundleId()}\n"; 21 | echo "Environment: {$transactionInfo->getEnvironment()}\n"; 22 | echo "Expires Date: {$transactionInfo->getExpiresDate()}\n"; 23 | echo "In-App Ownership Type: {$transactionInfo->getInAppOwnershipType()}\n"; 24 | echo "Is Upgraded: {$transactionInfo->getIsUpgraded()}\n"; 25 | echo "Offer Identifier: {$transactionInfo->getOfferIdentifier()}\n"; 26 | echo "Offer Type: {$transactionInfo->getOfferType()}\n"; 27 | echo "Original Purchase Date: {$transactionInfo->getOriginalPurchaseDate()}\n"; 28 | echo "Original Transaction ID: {$transactionInfo->getOriginalTransactionId()}\n"; 29 | echo "Product ID: {$transactionInfo->getProductId()}\n"; 30 | echo "Purchase Date: {$transactionInfo->getPurchaseDate()}\n"; 31 | echo "Quantity: {$transactionInfo->getQuantity()}\n"; 32 | echo "Revocation Date: {$transactionInfo->getRevocationDate()}\n"; 33 | echo "Revocation Reason: {$transactionInfo->getRevocationReason()}\n"; 34 | echo "Signed Date: {$transactionInfo->getSignedDate()}\n"; 35 | echo "Subscription Group Identifier: {$transactionInfo->getSubscriptionGroupIdentifier()}\n"; 36 | echo "Transaction ID: {$transactionInfo->getTransactionId()}\n"; 37 | echo "Type: {$transactionInfo->getType()}\n"; 38 | echo "Web Order Line Item ID: {$transactionInfo->getWebOrderLineItemId()}\n"; 39 | 40 | echo "\nAs JSON: " . json_encode($transactionInfo) . "\n\n"; 41 | } 42 | -------------------------------------------------------------------------------- /src/Response/ExtendRenewalDateResponse.php: -------------------------------------------------------------------------------- 1 | effectiveDate; 39 | } 40 | 41 | /** 42 | * Returns the original transaction identifier of the subscription that experienced a service interruption. 43 | */ 44 | public function getOriginalTransactionId(): string 45 | { 46 | return $this->originalTransactionId; 47 | } 48 | 49 | /** 50 | * Returns a Boolean value that indicates whether the subscription-renewal-date extension succeeded. 51 | */ 52 | public function isSuccess(): bool 53 | { 54 | return $this->success; 55 | } 56 | 57 | /** 58 | * Returns unique ID that identifies subscription-purchase events, including subscription renewals, across devices. 59 | */ 60 | public function getWebOrderLineItemId(): string 61 | { 62 | return $this->webOrderLineItemId; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /examples/lookUpOrderId.php: -------------------------------------------------------------------------------- 1 | $api, 'credentials' => $credentials] = require 'client.php'; 5 | 6 | if (empty($credentials['orderId'])) { 7 | exit('You have to specify orderId in order to look it up'); 8 | } 9 | 10 | use Readdle\AppStoreServerAPI\Exception\AppStoreServerAPIException; 11 | 12 | try { 13 | $orderLookup = $api->lookUpOrderId($credentials['orderId']); 14 | } catch (AppStoreServerAPIException $e) { 15 | exit($e->getMessage()); 16 | } 17 | 18 | echo "Order Lookup\n"; 19 | echo "Status: {$orderLookup->getStatus()}\n\n"; 20 | 21 | foreach ($orderLookup->getTransactions() as $i => $transactionInfo) { 22 | echo "Transaction Info #$i\n"; 23 | echo "App Account Token: {$transactionInfo->getAppAccountToken()}\n"; 24 | echo "Bundle ID: {$transactionInfo->getBundleId()}\n"; 25 | echo "Environment: {$transactionInfo->getEnvironment()}\n"; 26 | echo "Expires Date: {$transactionInfo->getExpiresDate()}\n"; 27 | echo "In-App Ownership Type: {$transactionInfo->getInAppOwnershipType()}\n"; 28 | echo "Is Upgraded: {$transactionInfo->getIsUpgraded()}\n"; 29 | echo "Offer Identifier: {$transactionInfo->getOfferIdentifier()}\n"; 30 | echo "Offer Type: {$transactionInfo->getOfferType()}\n"; 31 | echo "Original Purchase Date: {$transactionInfo->getOriginalPurchaseDate()}\n"; 32 | echo "Original Transaction ID: {$transactionInfo->getOriginalTransactionId()}\n"; 33 | echo "Product ID: {$transactionInfo->getProductId()}\n"; 34 | echo "Purchase Date: {$transactionInfo->getPurchaseDate()}\n"; 35 | echo "Quantity: {$transactionInfo->getQuantity()}\n"; 36 | echo "Revocation Date: {$transactionInfo->getRevocationDate()}\n"; 37 | echo "Revocation Reason: {$transactionInfo->getRevocationReason()}\n"; 38 | echo "Signed Date: {$transactionInfo->getSignedDate()}\n"; 39 | echo "Subscription Group Identifier: {$transactionInfo->getSubscriptionGroupIdentifier()}\n"; 40 | echo "Transaction ID: {$transactionInfo->getTransactionId()}\n"; 41 | echo "Type: {$transactionInfo->getType()}\n"; 42 | echo "Web Order Line Item ID: {$transactionInfo->getWebOrderLineItemId()}\n"; 43 | 44 | echo "\nAs JSON: " . json_encode($transactionInfo) . "\n\n"; 45 | } 46 | -------------------------------------------------------------------------------- /src/SubscriptionGroupIdentifierItem.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | private array $lastTransactions; 23 | 24 | /** 25 | * @param array $lastTransactions 26 | */ 27 | private function __construct(string $subscriptionGroupIdentifier, array $lastTransactions) 28 | { 29 | $this->subscriptionGroupIdentifier = $subscriptionGroupIdentifier; 30 | $this->lastTransactions = $lastTransactions; 31 | } 32 | 33 | /** 34 | * @param array $rawItem 35 | * 36 | * @throws MalformedJWTException 37 | */ 38 | public static function createFromRawItem(array $rawItem): self 39 | { 40 | $lastTransactions = []; 41 | 42 | foreach ($rawItem['lastTransactions'] as $rawTransactionItem) { 43 | $lastTransactions[] = LastTransactionsItem::createFromRawItem($rawTransactionItem); 44 | } 45 | 46 | return new self($rawItem['subscriptionGroupIdentifier'], $lastTransactions); 47 | } 48 | 49 | /** 50 | * Returns the subscription group identifier of the auto-renewable subscriptions in the lastTransactions array. 51 | */ 52 | public function getSubscriptionGroupIdentifier(): string 53 | { 54 | return $this->subscriptionGroupIdentifier; 55 | } 56 | 57 | /** 58 | * Returns an array of the most recent transaction information and renewal information for all auto-renewable 59 | * subscriptions in the subscription group. 60 | * 61 | * @return array 62 | */ 63 | public function getLastTransactions(): array 64 | { 65 | return $this->lastTransactions; 66 | } 67 | 68 | /** 69 | * @return array 70 | */ 71 | public function jsonSerialize(): array 72 | { 73 | return get_object_vars($this); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Util/Helper.php: -------------------------------------------------------------------------------- 1 | $input 21 | * @param array> $typeCastMap 22 | */ 23 | public static function arrayTypeCastGenerator(array $input, array $typeCastMap): Generator 24 | { 25 | foreach ($typeCastMap as $type => $keys) { 26 | foreach ($keys as $key) { 27 | if (!array_key_exists($key, $input)) { 28 | continue; 29 | } 30 | 31 | switch ($type) { 32 | case 'int': 33 | yield $key => (int) $input[$key]; 34 | break; 35 | 36 | case 'bool': 37 | yield $key => (bool) $input[$key]; 38 | break; 39 | 40 | case 'float': 41 | yield $key => (float) $input[$key]; 42 | break; 43 | 44 | case 'string': 45 | yield $key => (string) $input[$key]; 46 | break; 47 | 48 | default: 49 | yield $key => null; 50 | } 51 | } 52 | } 53 | } 54 | 55 | public static function base64Encode(string $input): string 56 | { 57 | return str_replace('=', '', strtr(base64_encode($input), '+/', '-_')); 58 | } 59 | 60 | public static function base64Decode(string $input): string 61 | { 62 | $remainder = strlen($input) % 4; 63 | 64 | if ($remainder) { 65 | $input .= str_repeat('=', 4 - $remainder); 66 | } 67 | 68 | return base64_decode(strtr($input, '-_', '+/')); 69 | } 70 | 71 | public static function formatPEM(string $certificate): string 72 | { 73 | if (strpos($certificate, "-----BEGIN CERTIFICATE-----\n") !== false) { 74 | return $certificate; 75 | } 76 | 77 | return join("\n", [ 78 | "-----BEGIN CERTIFICATE-----", 79 | $certificate, 80 | "-----END CERTIFICATE-----" 81 | ]); 82 | } 83 | 84 | public static function toPEM(string $binary): string 85 | { 86 | return trim(chunk_split(base64_encode($binary), 64)); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/RequestBody/MassExtendRenewalDateRequestBody.php: -------------------------------------------------------------------------------- 1 | 61 | */ 62 | protected array $storefrontCountryCodes = []; 63 | 64 | protected array $requiredFields = ['extendByDays', 'extendReasonCode', 'productId', 'requestIdentifier']; 65 | 66 | /** 67 | * @throws Exception 68 | */ 69 | public function __construct(array $params = []) 70 | { 71 | parent::__construct($params); 72 | 73 | if ($this->extendByDays < 1 || $this->extendByDays > 90) { 74 | throw new Exception('"extendByDays" should be numeric value in range from 1 to 90'); 75 | } 76 | 77 | if (strlen($this->requestIdentifier) > 128) { 78 | throw new Exception('"requestIdentifier" should be string value with length from 1 to 128'); 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/HTTPRequest.php: -------------------------------------------------------------------------------- 1 | getKey(), $request->getPayload()); 31 | } catch (JWTCreationException|Exception $e) { 32 | throw new HTTPRequestAborted('Authorization token could not be generated', $e); 33 | } 34 | 35 | $httpMethod = $request->getHTTPMethod(); 36 | 37 | $options = [ 38 | 'http' => [ 39 | 'method' => $httpMethod, 40 | 'header' => "Authorization: Bearer $token", 41 | 'ignore_errors' => true, 42 | ] 43 | ]; 44 | 45 | if (in_array($httpMethod, [AbstractRequest::HTTP_METHOD_POST, AbstractRequest::HTTP_METHOD_PUT])) { 46 | $body = $request->getBody(); 47 | 48 | if ($body) { 49 | $options['http']['header'] .= "\r\nContent-Type: {$body->getContentType()}"; 50 | $options['http']['content'] = $body->getEncodedContent(); 51 | } 52 | } 53 | 54 | $url = $request->composeURL(); 55 | 56 | $response = file_get_contents( 57 | $url, 58 | false, 59 | stream_context_create($options) 60 | ); 61 | 62 | if (empty($http_response_header)) { 63 | throw new HTTPRequestFailed($httpMethod, $url, 'No response headers found, probably empty response'); 64 | } 65 | 66 | $statusLine = reset($http_response_header); 67 | 68 | if (!preg_match('/^HTTP\/\d\.\d (?\d+) (?[^\n\r]+)$/', $statusLine, $matches)) { 69 | throw new HTTPRequestFailed($httpMethod, $url, "Wrong Status-Line: $statusLine"); 70 | } 71 | 72 | $statusCode = (int) $matches['statusCode']; 73 | 74 | if (!in_array($statusCode, [200, 202])) { 75 | throw new HTTPRequestFailed($httpMethod, $url, $response, $statusCode); 76 | } 77 | 78 | return $response; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/RequestBody/NotificationHistoryRequestBody.php: -------------------------------------------------------------------------------- 1 | */ 27 | protected array $urlVars = ['baseUrl' => '']; 28 | 29 | public function __construct( 30 | Key $key, 31 | Payload $payload, 32 | ?AbstractRequestQueryParams $queryParams, 33 | ?AbstractRequestBody $body 34 | ) { 35 | $this->key = $key; 36 | $this->payload = $payload; 37 | $this->queryParams = $queryParams; 38 | $this->body = $body; 39 | } 40 | 41 | public function getKey(): Key 42 | { 43 | return $this->key; 44 | } 45 | 46 | public function getPayload(): Payload 47 | { 48 | return $this->payload; 49 | } 50 | 51 | public function getQueryParams(): ?AbstractRequestQueryParams 52 | { 53 | return $this->queryParams; 54 | } 55 | 56 | public function getBody(): ?AbstractRequestBody 57 | { 58 | return $this->body; 59 | } 60 | 61 | /** 62 | * @return array 63 | */ 64 | public function getURLVars(): array 65 | { 66 | return $this->urlVars; 67 | } 68 | 69 | /** 70 | * @param array $urlVars 71 | */ 72 | public function setURLVars(array $urlVars): void 73 | { 74 | $this->urlVars = array_merge($this->urlVars, $urlVars); 75 | } 76 | 77 | public function composeURL(): string 78 | { 79 | $tail = ''; 80 | 81 | if ($this->queryParams !== null) { 82 | $queryString = $this->queryParams->getQueryString(); 83 | 84 | if (!empty($queryString)) { 85 | $tail = '?' . $queryString; 86 | } 87 | } 88 | 89 | return preg_replace_callback( 90 | '/{\w+}/', 91 | fn ($match) => $this->urlVars[trim($match[0], '{}')] ?? $match[0], 92 | $this->getURLPattern() 93 | ) . $tail; 94 | } 95 | 96 | abstract public function getHTTPMethod(): string; 97 | 98 | abstract protected function getURLPattern(): string; 99 | } 100 | -------------------------------------------------------------------------------- /examples/getTransactionHistory.php: -------------------------------------------------------------------------------- 1 | $api, 'credentials' => $credentials] = require 'client.php'; 5 | 6 | use Readdle\AppStoreServerAPI\Exception\AppStoreServerAPIException; 7 | 8 | try { 9 | $transactionHistory = $api->getTransactionHistory($credentials['transactionId'], ['sort' => 'ASCENDING']); 10 | $transactions = $transactionHistory->getTransactions(); 11 | } catch (AppStoreServerAPIException $e) { 12 | exit($e->getMessage()); 13 | } 14 | 15 | echo "App Apple ID: {$transactionHistory->getAppAppleId()}\n"; 16 | echo "Bundle ID: {$transactionHistory->getBundleId()}\n"; 17 | echo "Environment: {$transactionHistory->getEnvironment()}\n"; 18 | echo "\n"; 19 | 20 | foreach ($transactions as $i => $transactionInfo) { 21 | echo "Transaction Info #$i\n"; 22 | echo "App Account Token: {$transactionInfo->getAppAccountToken()}\n"; 23 | echo "Bundle ID: {$transactionInfo->getBundleId()}\n"; 24 | echo "Currency: {$transactionInfo->getCurrency()}\n"; 25 | echo "Environment: {$transactionInfo->getEnvironment()}\n"; 26 | echo "Expires Date: {$transactionInfo->getExpiresDate()}\n"; 27 | echo 'Expires Date UTC: ' . date('Y-m-d H::i:s', intval($transactionInfo->getExpiresDate() / 1000)) . "\n"; 28 | echo "In-App Ownership Type: {$transactionInfo->getInAppOwnershipType()}\n"; 29 | echo "Is Upgraded: {$transactionInfo->getIsUpgraded()}\n"; 30 | echo "Offer Discount Type: {$transactionInfo->getOfferDiscountType()}\n"; 31 | echo "Offer Identifier: {$transactionInfo->getOfferIdentifier()}\n"; 32 | echo "Offer Type: {$transactionInfo->getOfferType()}\n"; 33 | echo "Original Purchase Date: {$transactionInfo->getOriginalPurchaseDate()}\n"; 34 | echo "Original Transaction ID: {$transactionInfo->getOriginalTransactionId()}\n"; 35 | echo "Price: {$transactionInfo->getPrice()}\n"; 36 | echo "Product ID: {$transactionInfo->getProductId()}\n"; 37 | echo "Purchase Date: {$transactionInfo->getPurchaseDate()}\n"; 38 | echo 'Purchase Date UTC: ' . date('Y-m-d H::i:s', intval($transactionInfo->getPurchaseDate() / 1000)) . "\n"; 39 | echo "Quantity: {$transactionInfo->getQuantity()}\n"; 40 | echo "Revocation Date: {$transactionInfo->getRevocationDate()}\n"; 41 | echo 'Revocation Date UTC: ' . ($transactionInfo->getRevocationDate() ? date('Y-m-d H::i:s', intval($transactionInfo->getRevocationDate() / 1000)) : '') . "\n"; 42 | echo "Revocation Reason: {$transactionInfo->getRevocationReason()}\n"; 43 | echo "Signed Date: {$transactionInfo->getSignedDate()}\n"; 44 | echo "Subscription Group Identifier: {$transactionInfo->getSubscriptionGroupIdentifier()}\n"; 45 | echo "Transaction ID: {$transactionInfo->getTransactionId()}\n"; 46 | echo "Type: {$transactionInfo->getType()}\n"; 47 | echo "Web Order Line Item ID: {$transactionInfo->getWebOrderLineItemId()}\n"; 48 | 49 | echo "\nAs JSON: " . json_encode($transactionInfo) . "\n\n"; 50 | } 51 | -------------------------------------------------------------------------------- /src/RequestQueryParams/GetTransactionHistoryQueryParams.php: -------------------------------------------------------------------------------- 1 | 36 | */ 37 | protected array $productId = []; 38 | 39 | /** 40 | * An optional filter that indicates the product type to include in the transaction history. 41 | * Your query may specify more than one productType. 42 | * 43 | * @var array 44 | */ 45 | protected array $productType = []; 46 | 47 | /** 48 | * An optional sort order for the transaction history records. 49 | * The response sorts the transaction records by their recently modified date. 50 | * The default value is ASCENDING, so you receive the oldest records first. 51 | */ 52 | protected string $sort = self::SORT__ASCENDING; 53 | 54 | /** 55 | * An optional filter that indicates the subscription group identifier to include in the transaction history. 56 | * Your query may specify more than one subscriptionGroupIdentifier. 57 | * 58 | * @var array 59 | */ 60 | protected array $subscriptionGroupIdentifier = []; 61 | 62 | /** 63 | * An optional filter that limits the transaction history by the in-app ownership type. 64 | */ 65 | protected string $inAppOwnershipType = ''; 66 | 67 | /** 68 | * An optional Boolean value that indicates whether the transaction history excludes refunded and revoked transactions. 69 | * The default value is false. 70 | */ 71 | protected bool $excludeRevoked = false; 72 | } 73 | -------------------------------------------------------------------------------- /src/Response/PageableResponse.php: -------------------------------------------------------------------------------- 1 | 43 | */ 44 | protected array $items = []; 45 | 46 | /** 47 | * @throws HTTPRequestAborted 48 | * @throws HTTPRequestFailed 49 | * @throws MalformedResponseException 50 | * @throws UnimplementedContentTypeException 51 | */ 52 | protected function getItems(): Generator 53 | { 54 | $page = $this; 55 | 56 | do { 57 | foreach ($page->items as $item) { 58 | yield $item; 59 | } 60 | 61 | if ($page->hasMore) { 62 | $queryParams = $page->originalRequest->getQueryParams(); 63 | 64 | if (is_subclass_of($queryParams, PageableQueryParams::class)) { 65 | $class = get_class($page->originalRequest); 66 | $nextRequest = new $class( 67 | $page->originalRequest->getKey(), 68 | $page->originalRequest->getPayload(), 69 | $queryParams->forRevision($page->revision), 70 | $page->originalRequest->getBody() 71 | ); 72 | 73 | $nextRequest->setURLVars($page->originalRequest->getURLVars()); 74 | $responseText = HTTPRequest::performRequest($nextRequest); 75 | $page->nextPage = static::createFromString($responseText, $nextRequest); 76 | } 77 | } 78 | 79 | $page = $page->nextPage; 80 | } while ($page); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Response/MassExtendRenewalDateStatusResponse.php: -------------------------------------------------------------------------------- 1 | requestIdentifier; 49 | } 50 | 51 | /** 52 | * Returns a Boolean value that’s `true` to indicate that the App Store completed your request to extend 53 | * a subscription renewal date for all eligible subscribers. 54 | * The value is `false` if the request is in progress. 55 | */ 56 | public function isComplete(): bool 57 | { 58 | return $this->complete; 59 | } 60 | 61 | /** 62 | * Returns the date that the App Store completes the request. 63 | * Returns `null` if complete is `false`. 64 | */ 65 | public function getCompleteDate(): ?int 66 | { 67 | return $this->completeDate; 68 | } 69 | 70 | /** 71 | * Returns the final count of subscribers that fail to receive a subscription-renewal-date extension. 72 | * Returns `null` if complete is `false`. 73 | */ 74 | public function getFailedCount(): ?int 75 | { 76 | return $this->failedCount; 77 | } 78 | 79 | /** 80 | * Returns the final count of subscribers that successfully receive a subscription-renewal-date extension. 81 | * Returns `null` if complete is `false`. 82 | */ 83 | public function getSucceededCount(): ?int 84 | { 85 | return $this->succeededCount; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Response/StatusResponse.php: -------------------------------------------------------------------------------- 1 | 38 | */ 39 | protected array $data = []; 40 | 41 | /** 42 | * @param array $properties 43 | * 44 | * @throws MalformedJWTException 45 | */ 46 | protected function __construct(array $properties, AbstractRequest $originalRequest) 47 | { 48 | foreach ($properties['data'] as $rawSubscriptionGroupIdentifierItem) { 49 | $this->data[] = SubscriptionGroupIdentifierItem::createFromRawItem($rawSubscriptionGroupIdentifierItem); 50 | } 51 | 52 | unset($properties['data']); 53 | parent::__construct($properties, $originalRequest); 54 | } 55 | 56 | /** 57 | * Returns the app's identifier in the App Store. 58 | * This property is available for apps that are downloaded from the App Store; it isn't present in the sandbox 59 | * environment. 60 | */ 61 | public function getAppAppleId(): ?int 62 | { 63 | return $this->appAppleId; 64 | } 65 | 66 | /** 67 | * Returns the bundle identifier of the app. 68 | */ 69 | public function getBundleId(): string 70 | { 71 | return $this->bundleId; 72 | } 73 | 74 | /** 75 | * Returns the server environment in which you're making the request, whether sandbox or production. 76 | * 77 | * @return Environment::PRODUCTION|Environment::SANDBOX 78 | */ 79 | public function getEnvironment(): string 80 | { 81 | return $this->environment; 82 | } 83 | 84 | /** 85 | * Returns an array of information for auto-renewable subscriptions, 86 | * including transaction information and renewal information. 87 | * 88 | * @return array 89 | */ 90 | public function getData(): array 91 | { 92 | return $this->data; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/NotificationHistoryResponseItem.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | protected array $sendAttempts; 18 | 19 | /** 20 | * The cryptographically signed payload, in JSON Web Signature (JWS) format, containing the original response body 21 | * of a version 2 notification. 22 | */ 23 | protected string $signedPayload; 24 | 25 | /** 26 | * The original response body of a version 2 notification. 27 | */ 28 | protected ?ResponseBodyV2 $responseBodyV2 = null; 29 | 30 | /** 31 | * @param array $sendAttempts 32 | */ 33 | private function __construct(array $sendAttempts, string $signedPayload) 34 | { 35 | $this->sendAttempts = $sendAttempts; 36 | $this->signedPayload = $signedPayload; 37 | 38 | try { 39 | $this->responseBodyV2 = ResponseBodyV2::createFromRawNotification("{\"signedPayload\":\"$signedPayload\"}"); 40 | } catch (AppStoreServerNotificationException $e) { 41 | // nothing to do with this hypothetical situation 42 | } 43 | } 44 | 45 | /** 46 | * @param array $rawItem 47 | */ 48 | public static function createFromRawItem(array $rawItem): self 49 | { 50 | $sendAttempts = []; 51 | 52 | foreach ($rawItem['sendAttempts'] as $rawSendAttempt) { 53 | $sendAttempts[] = SendAttemptItem::createFromRawSendAttempt($rawSendAttempt); 54 | } 55 | 56 | return new self($sendAttempts, $rawItem['signedPayload']); 57 | } 58 | 59 | /** 60 | * Returns an array of information the App Store server records for its attempts to send a notification to your 61 | * server. 62 | * The maximum number of entries in the array is six. 63 | * 64 | * @return array 65 | */ 66 | public function getSendAttempts(): array 67 | { 68 | return $this->sendAttempts; 69 | } 70 | 71 | /** 72 | * Returns the cryptographically signed payload, in JSON Web Signature (JWS) format, containing the original 73 | * response body of a version 2 notification. 74 | */ 75 | public function getSignedPayload(): string 76 | { 77 | return $this->signedPayload; 78 | } 79 | 80 | /** 81 | * Returns the original response body of a version 2 notification. 82 | */ 83 | public function getResponseBodyV2(): ?ResponseBodyV2 84 | { 85 | return $this->responseBodyV2; 86 | } 87 | 88 | /** 89 | * @return array 90 | */ 91 | public function jsonSerialize(): array 92 | { 93 | return get_object_vars($this); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Response/HistoryResponse.php: -------------------------------------------------------------------------------- 1 | items[] = TransactionInfo::createFromRawTransactionInfo(JWT::parse($signedTransactionInfo)); 46 | } 47 | 48 | unset($properties['signedTransactions']); 49 | parent::__construct($properties, $originalRequest); 50 | } 51 | 52 | /** 53 | * Returns the app's identifier in the App Store. 54 | * This property is available for apps that are downloaded from the App Store; it isn't present in the sandbox 55 | * environment. 56 | */ 57 | public function getAppAppleId(): ?int 58 | { 59 | return $this->appAppleId; 60 | } 61 | 62 | /** 63 | * Returns the bundle identifier of the app. 64 | */ 65 | public function getBundleId(): string 66 | { 67 | return $this->bundleId; 68 | } 69 | 70 | /** 71 | * Returns the server environment in which you're making the request, whether sandbox or production. 72 | * 73 | * @return Environment::PRODUCTION|Environment::SANDBOX 74 | */ 75 | public function getEnvironment(): string 76 | { 77 | return $this->environment; 78 | } 79 | 80 | /** 81 | * Returns a Generator which iterates over an array of in-app purchase transactions for the customer. 82 | * 83 | * @return Generator 84 | * 85 | * @throws HTTPRequestAborted 86 | * @throws HTTPRequestFailed 87 | * @throws MalformedResponseException 88 | * @throws UnimplementedContentTypeException 89 | */ 90 | public function getTransactions(): Generator 91 | { 92 | return $this->getItems(); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/LastTransactionsItem.php: -------------------------------------------------------------------------------- 1 | originalTransactionId = $originalTransactionId; 64 | $this->status = $status; 65 | $this->renewalInfo = $renewalInfo; 66 | $this->transactionInfo = $transactionInfo; 67 | } 68 | 69 | /** 70 | * @param array $rawItem 71 | * 72 | * @throws MalformedJWTException 73 | */ 74 | public static function createFromRawItem(array $rawItem): self 75 | { 76 | $renewalInfo = RenewalInfo::createFromRawRenewalInfo(JWT::parse($rawItem['signedRenewalInfo'])); 77 | $transactionInfo = TransactionInfo::createFromRawTransactionInfo(JWT::parse($rawItem['signedTransactionInfo'])); 78 | return new self($rawItem['originalTransactionId'], $rawItem['status'], $renewalInfo, $transactionInfo); 79 | } 80 | 81 | /** 82 | * Returns the original transaction identifier of the auto-renewable subscription. 83 | */ 84 | public function getOriginalTransactionId(): string 85 | { 86 | return $this->originalTransactionId; 87 | } 88 | 89 | /** 90 | * Returns the status of the auto-renewable subscription. 91 | * 92 | * @return self::STATUS__* 93 | */ 94 | public function getStatus(): int 95 | { 96 | /** @phpstan-ignore-next-line */ 97 | return $this->status; 98 | } 99 | 100 | /** 101 | * Returns the subscription renewal information. 102 | */ 103 | public function getRenewalInfo(): RenewalInfo 104 | { 105 | return $this->renewalInfo; 106 | } 107 | 108 | /** 109 | * Returns the transaction information. 110 | */ 111 | public function getTransactionInfo(): TransactionInfo 112 | { 113 | return $this->transactionInfo; 114 | } 115 | 116 | /** 117 | * @return array 118 | */ 119 | public function jsonSerialize(): array 120 | { 121 | return get_object_vars($this); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/Util/ASN1SequenceOfInteger.php: -------------------------------------------------------------------------------- 1 | self::ASN1_BIG_INT_MAX_FIRST_BYTE) { 49 | array_shift($bytesArray); 50 | } 51 | 52 | $hexParts[] = join(array_map( 53 | fn (string $chr) => str_pad(dechex(ord($chr)), 2, '0', STR_PAD_LEFT), 54 | $bytesArray 55 | )); 56 | 57 | $position += $length; 58 | } while ($position < $asn1Length); 59 | 60 | $maxLength = array_reduce($hexParts, fn (int $carry, string $item) => max($carry, strlen($item)), 0); 61 | return join(array_map(fn (string $hex) => str_pad($hex, $maxLength, '0', STR_PAD_LEFT), $hexParts)); 62 | } 63 | 64 | /** 65 | * @throws Exception 66 | */ 67 | public static function fromHex(string $hexSignature): string 68 | { 69 | $length = strlen($hexSignature); 70 | 71 | if ($length % 2) { 72 | throw new Exception('Invalid signature length'); 73 | } 74 | 75 | $hexParts = str_split($hexSignature, $length / 2); 76 | 77 | foreach ($hexParts as &$hexPart) { 78 | $firstByteHex = substr($hexPart, 0, 2); 79 | 80 | if (hexdec($firstByteHex) > self::ASN1_BIG_INT_MAX_FIRST_BYTE) { 81 | $hexPart = '00' . $hexPart; 82 | } else { 83 | while ($firstByteHex === '00' && hexdec(substr($hexPart, 2, 2)) <= self::ASN1_BIG_INT_MAX_FIRST_BYTE) { 84 | $hexPart = substr($hexPart, 2); 85 | $firstByteHex = substr($hexPart, 0, 2); 86 | } 87 | } 88 | } 89 | 90 | $encodedIntegers = join(array_map( 91 | fn (string $hexPart) => join([ 92 | chr(self::ASN1_INTEGER), 93 | chr(strlen($hexPart) / 2), 94 | join(array_map(fn (string $hex) => chr(hexdec($hex)), str_split($hexPart, 2))), 95 | ]), 96 | $hexParts 97 | )); 98 | 99 | return join([ 100 | chr(self::ASN1_CONSTRUCTED | self::ASN1_SEQUENCE_IDENTIFIER), 101 | chr(strlen($encodedIntegers)), 102 | $encodedIntegers, 103 | ]); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /examples/getAllSubscriptionStatuses.php: -------------------------------------------------------------------------------- 1 | $api, 'credentials' => $credentials] = require 'client.php'; 5 | 6 | use Readdle\AppStoreServerAPI\Exception\AppStoreServerAPIException; 7 | 8 | try { 9 | $allSubscriptionStatuses = $api->getAllSubscriptionStatuses($credentials['transactionId']); 10 | } catch (AppStoreServerAPIException $e) { 11 | exit($e->getMessage()); 12 | } 13 | 14 | echo "App Apple ID: {$allSubscriptionStatuses->getAppAppleId()}\n"; 15 | echo "Bundle ID: {$allSubscriptionStatuses->getBundleId()}\n"; 16 | echo "Environment: {$allSubscriptionStatuses->getEnvironment()}\n"; 17 | echo "\n"; 18 | 19 | foreach ($allSubscriptionStatuses->getData() as $subscriptionGroupIdentifierItem) { 20 | echo "Subscription Group Identifier: {$subscriptionGroupIdentifierItem->getSubscriptionGroupIdentifier()}\n"; 21 | echo "Last Transactions\n"; 22 | 23 | foreach ($subscriptionGroupIdentifierItem->getLastTransactions() as $i => $lastTransactionsItem) { 24 | echo "Original Transaction ID: {$lastTransactionsItem->getOriginalTransactionId()}\n"; 25 | echo "Status: {$lastTransactionsItem->getStatus()}\n"; 26 | 27 | $transactionInfo = $lastTransactionsItem->getTransactionInfo(); 28 | echo "\nTransaction Info\n"; 29 | echo "App Account Token: {$transactionInfo->getAppAccountToken()}\n"; 30 | echo "Bundle ID: {$transactionInfo->getBundleId()}\n"; 31 | echo "Environment: {$transactionInfo->getEnvironment()}\n"; 32 | echo "Expires Date: {$transactionInfo->getExpiresDate()}\n"; 33 | echo "In-App Ownership Type: {$transactionInfo->getInAppOwnershipType()}\n"; 34 | echo "Is Upgraded: {$transactionInfo->getIsUpgraded()}\n"; 35 | echo "Offer Identifier: {$transactionInfo->getOfferIdentifier()}\n"; 36 | echo "Offer Type: {$transactionInfo->getOfferType()}\n"; 37 | echo "Original Purchase Date: {$transactionInfo->getOriginalPurchaseDate()}\n"; 38 | echo "Original Transaction ID: {$transactionInfo->getOriginalTransactionId()}\n"; 39 | echo "Product ID: {$transactionInfo->getProductId()}\n"; 40 | echo "Purchase Date: {$transactionInfo->getPurchaseDate()}\n"; 41 | echo "Quantity: {$transactionInfo->getQuantity()}\n"; 42 | echo "Revocation Date: {$transactionInfo->getRevocationDate()}\n"; 43 | echo "Revocation Reason: {$transactionInfo->getRevocationReason()}\n"; 44 | echo "Signed Date: {$transactionInfo->getSignedDate()}\n"; 45 | echo "Subscription Group Identifier: {$transactionInfo->getSubscriptionGroupIdentifier()}\n"; 46 | echo "Transaction ID: {$transactionInfo->getTransactionId()}\n"; 47 | echo "Type: {$transactionInfo->getType()}\n"; 48 | echo "Web Order Line Item ID: {$transactionInfo->getWebOrderLineItemId()}\n"; 49 | 50 | echo "\nAs JSON: " . json_encode($transactionInfo) . "\n"; 51 | 52 | $renewalInfo = $lastTransactionsItem->getRenewalInfo(); 53 | echo "\nRenewal Info\n"; 54 | echo "Auto Renew Product ID: {$renewalInfo->getAutoRenewProductId()}\n"; 55 | echo "Auto Renew Status: {$renewalInfo->getAutoRenewStatus()}\n"; 56 | echo "Environment: {$renewalInfo->getEnvironment()}\n"; 57 | echo "Expiration Intent: {$renewalInfo->getExpirationIntent()}\n"; 58 | echo "Grace Period Expires Date: {$renewalInfo->getGracePeriodExpiresDate()}\n"; 59 | echo "Is In Billing Retry Period: {$renewalInfo->getIsInBillingRetryPeriod()}\n"; 60 | echo "Offer Identifier: {$renewalInfo->getOfferIdentifier()}\n"; 61 | echo "Offer Type: {$renewalInfo->getOfferType()}\n"; 62 | echo "Original Transaction ID: {$renewalInfo->getOriginalTransactionId()}\n"; 63 | echo "Price Increase Status: {$renewalInfo->getPriceIncreaseStatus()}\n"; 64 | echo "Product ID: {$renewalInfo->getProductId()}\n"; 65 | echo "Recent Subscription Start Date: {$renewalInfo->getRecentSubscriptionStartDate()}\n"; 66 | echo "Signed Date: {$renewalInfo->getSignedDate()}\n"; 67 | 68 | echo "\nAs JSON: " . json_encode($renewalInfo) . "\n"; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/SendAttemptItem.php: -------------------------------------------------------------------------------- 1 | attemptDate = $attemptDate; 81 | $this->sendAttemptResult = $sendAttemptResult; 82 | } 83 | 84 | /** 85 | * @param array $rawSendAttempt 86 | */ 87 | public static function createFromRawSendAttempt(array $rawSendAttempt): self 88 | { 89 | return new self($rawSendAttempt['attemptDate'], $rawSendAttempt['sendAttemptResult']); 90 | } 91 | 92 | /** 93 | * Returns the date the App Store server attempts to send the notification. 94 | */ 95 | public function getAttemptDate(): int 96 | { 97 | return $this->attemptDate; 98 | } 99 | 100 | /** 101 | * Returns the success or error information the App Store server records when 102 | * it attempts to send an App Store server notification to your server. 103 | * 104 | * @return self::SEND_ATTEMPT_RESULT__* 105 | */ 106 | public function getSendAttemptResult(): string 107 | { 108 | return $this->sendAttemptResult; 109 | } 110 | 111 | /** 112 | * @return array 113 | */ 114 | public function jsonSerialize(): array 115 | { 116 | return get_object_vars($this); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /examples/serverNotificationV2.php: -------------------------------------------------------------------------------- 1 | getMessage()); 24 | } 25 | 26 | echo "Notification type: {$responseBodyV2->getNotificationType()}\n"; 27 | echo "Notification subtype: {$responseBodyV2->getSubtype()}\n"; 28 | echo "Notification UUID: {$responseBodyV2->getNotificationUUID()}\n"; 29 | echo "Version: {$responseBodyV2->getVersion()}\n"; 30 | echo "Signed date: {$responseBodyV2->getSignedDate()}\n"; 31 | 32 | echo "\nAs JSON: " . json_encode($responseBodyV2) . "\n"; 33 | 34 | $appMetadata = $responseBodyV2->getAppMetadata(); 35 | echo "\nApp Metadata\n"; 36 | echo "App Apple ID: {$appMetadata->getAppAppleId()}\n"; 37 | echo "Bundle ID: {$appMetadata->getBundleId()}\n"; 38 | echo "Bundle Version: {$appMetadata->getBundleVersion()}\n"; 39 | echo "Environment: {$appMetadata->getEnvironment()}\n"; 40 | 41 | echo "\nAs JSON: " . json_encode($appMetadata) . "\n"; 42 | 43 | $transactionInfo = $appMetadata->getTransactionInfo(); 44 | 45 | if ($transactionInfo) { 46 | echo "\nTransaction Info\n"; 47 | echo "App Account Token: {$transactionInfo->getAppAccountToken()}\n"; 48 | echo "Bundle ID: {$transactionInfo->getBundleId()}\n"; 49 | echo "Environment: {$transactionInfo->getEnvironment()}\n"; 50 | echo "Expires Date: {$transactionInfo->getExpiresDate()}\n"; 51 | echo "In-App Ownership Type: {$transactionInfo->getInAppOwnershipType()}\n"; 52 | echo "Is Upgraded: {$transactionInfo->getIsUpgraded()}\n"; 53 | echo "Offer Identifier: {$transactionInfo->getOfferIdentifier()}\n"; 54 | echo "Offer Type: {$transactionInfo->getOfferType()}\n"; 55 | echo "Original Purchase Date: {$transactionInfo->getOriginalPurchaseDate()}\n"; 56 | echo "Original Transaction ID: {$transactionInfo->getOriginalTransactionId()}\n"; 57 | echo "Product ID: {$transactionInfo->getProductId()}\n"; 58 | echo "Purchase Date: {$transactionInfo->getPurchaseDate()}\n"; 59 | echo "Quantity: {$transactionInfo->getQuantity()}\n"; 60 | echo "Revocation Date: {$transactionInfo->getRevocationDate()}\n"; 61 | echo "Revocation Reason: {$transactionInfo->getRevocationReason()}\n"; 62 | echo "Signed Date: {$transactionInfo->getSignedDate()}\n"; 63 | echo "Subscription Group Identifier: {$transactionInfo->getSubscriptionGroupIdentifier()}\n"; 64 | echo "Transaction ID: {$transactionInfo->getTransactionId()}\n"; 65 | echo "Type: {$transactionInfo->getType()}\n"; 66 | echo "Web Order Line Item ID: {$transactionInfo->getWebOrderLineItemId()}\n"; 67 | 68 | echo "\nAs JSON: " . json_encode($transactionInfo) . "\n"; 69 | } 70 | 71 | $renewalInfo = $appMetadata->getRenewalInfo(); 72 | 73 | if ($renewalInfo) { 74 | echo "\nRenewal Info\n"; 75 | echo "Auto Renew Product ID: {$renewalInfo->getAutoRenewProductId()}\n"; 76 | echo "Auto Renew Status: {$renewalInfo->getAutoRenewStatus()}\n"; 77 | echo "Environment: {$renewalInfo->getEnvironment()}\n"; 78 | echo "Expiration Intent: {$renewalInfo->getExpirationIntent()}\n"; 79 | echo "Grace Period Expires Date: {$renewalInfo->getGracePeriodExpiresDate()}\n"; 80 | echo "Is In Billing Retry Period: {$renewalInfo->getIsInBillingRetryPeriod()}\n"; 81 | echo "Offer Identifier: {$renewalInfo->getOfferIdentifier()}\n"; 82 | echo "Offer Type: {$renewalInfo->getOfferType()}\n"; 83 | echo "Original Transaction ID: {$renewalInfo->getOriginalTransactionId()}\n"; 84 | echo "Price Increase Status: {$renewalInfo->getPriceIncreaseStatus()}\n"; 85 | echo "Product ID: {$renewalInfo->getProductId()}\n"; 86 | echo "Recent Subscription Start Date: {$renewalInfo->getRecentSubscriptionStartDate()}\n"; 87 | echo "Signed Date: {$renewalInfo->getSignedDate()}\n"; 88 | 89 | echo "\nAs JSON: " . json_encode($renewalInfo) . "\n"; 90 | } 91 | -------------------------------------------------------------------------------- /src/AppMetadata.php: -------------------------------------------------------------------------------- 1 | $rawData 60 | */ 61 | public static function createFromRawData(array $rawData): self 62 | { 63 | $appMetadata = new self(); 64 | $typeCaster = Helper::arrayTypeCastGenerator($rawData, [ 65 | 'int' => ['status'], 66 | 'string' => ['appAppleId', 'bundleId', 'bundleVersion', 'environment'], 67 | ]); 68 | 69 | foreach ($typeCaster as $prop => $value) { 70 | $appMetadata->$prop = $value; 71 | } 72 | 73 | if (array_key_exists('renewalInfo', $rawData) && $rawData['renewalInfo'] instanceof RenewalInfo) { 74 | $appMetadata->renewalInfo = $rawData['renewalInfo']; 75 | } 76 | 77 | if (array_key_exists('transactionInfo', $rawData) && $rawData['transactionInfo'] instanceof TransactionInfo) { 78 | $appMetadata->transactionInfo = $rawData['transactionInfo']; 79 | } 80 | 81 | return $appMetadata; 82 | } 83 | 84 | /** 85 | * Returns the unique identifier of the app that the notification applies to. 86 | * This property is available for apps that are downloaded from the App Store; it isn't present in the sandbox 87 | * environment. 88 | */ 89 | public function getAppAppleId(): ?string 90 | { 91 | return $this->appAppleId; 92 | } 93 | 94 | /** 95 | * Returns the bundle identifier of the app. 96 | */ 97 | public function getBundleId(): ?string 98 | { 99 | return $this->bundleId; 100 | } 101 | 102 | /** 103 | * Returns the version of the build that identifies an iteration of the bundle. 104 | */ 105 | public function getBundleVersion(): ?string 106 | { 107 | return $this->bundleVersion; 108 | } 109 | 110 | /** 111 | * Returns the server environment that the notification applies to, either sandbox or production. 112 | * 113 | * @return Environment::PRODUCTION|Environment::SANDBOX 114 | */ 115 | public function getEnvironment(): string 116 | { 117 | return $this->environment; 118 | } 119 | 120 | /** 121 | * Returns subscription renewal information (if any). 122 | */ 123 | public function getRenewalInfo(): ?RenewalInfo 124 | { 125 | return $this->renewalInfo; 126 | } 127 | 128 | /** 129 | * Returns transaction information (if any). 130 | */ 131 | public function getTransactionInfo(): ?TransactionInfo 132 | { 133 | return $this->transactionInfo; 134 | } 135 | 136 | /** 137 | * Returns the status of an auto-renewable subscription as of the signedDate in the ResponseBodyV2. 138 | */ 139 | public function getStatus(): ?int 140 | { 141 | return $this->status; 142 | } 143 | 144 | /** 145 | * @return array 146 | */ 147 | public function jsonSerialize(): array 148 | { 149 | return get_object_vars($this); 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### [3.6.3] 2024-03-25 2 | 3 | **BUGFIX:** 4 | 5 | - Handle empty response headers in case if HTTP request to the API fails (and it fails regularly, kudos to Apple) 6 | 7 | ### [3.6.2] 2024-01-25 8 | 9 | **BUGFIX:** 10 | 11 | - If the certificate string already has a prefix, there is no need to add it 12 | 13 | ### [3.6.1] 2023-12-19 14 | 15 | **BUGFIX:** 16 | 17 | - Treat "202 Accepted" as successful response (App Store returns it on "Send consumption information" request), kudos to @teanooki for pointing this out 18 | 19 | ### [3.6.0] 2023-12-11 20 | 21 | **IMPROVEMENTS:** 22 | 23 | New fields implemented 24 | - `TransactionInfo`: `price`, `currency`, and `offerDiscountType` from [App Store Server API version 1.10](https://developer.apple.com/documentation/appstoreserverapi/app_store_server_api_changelog#4307459) 25 | 26 | ### [3.5.2] 2023-10-10 27 | 28 | **BUGFIX:** 29 | 30 | - Logic issue in PageableResponse, after fixing syntax issue in `3.5.1` 31 | 32 | ### [3.5.1] 2023-10-05 33 | 34 | **BUGFIX:** 35 | 36 | - Syntax issue in PageableResponse for PHP 7.4, kudos to @JamieSTV 37 | 38 | ### [3.5.0] 2023-09-21 39 | 40 | **IMPROVEMENTS:** 41 | 42 | Missing endpoints added: 43 | - Send Consumption Information 44 | - Extend a Subscription Renewal Date 45 | - Extend Subscription Renewal Dates for All Active Subscribers 46 | - Get Status of Subscription Renewal Date Extensions 47 | 48 | ### [3.4.1] 2023-09-17 49 | 50 | **BUGFIX:** 51 | 52 | - `TransactionInfo`: `storefront`, `storefrontId`, and `transactionReason` are now nullable and null by default, in order to be compatible with old notifications 53 | - `RenewalInfo`: `renewalDate` is now null by default, in order to be compatible with old notifications 54 | - `Response\NotificationHistoryResponse`: `paginationToken` presence in response is now optional 55 | 56 | ### [3.4.0] 2023-09-16 57 | 58 | **IMPROVEMENTS:** 59 | 60 | - New `notificationType`/`subtype` in `ResponseBodyV2` 61 | 62 | ### [3.3.2] 2023-09-16 63 | 64 | **BUGFIX:** 65 | 66 | - ASN1SequenceOfInteger: multiple `00` bytes in the beginning of integer numbers handled when parsing HEX signature representation 67 | 68 | ### [3.3.1] 2023-09-07 69 | 70 | **BUGFIX:** 71 | 72 | - `AppMetadata`: `bundleId`, `bundleVersion`, `renewalInfo`, `transactionInfo` and `status` now are `NULL` by default (to prevent `Typed property ... must not be accessed before initialization` error) 73 | 74 | ### [3.3.0] 2023-09-06 75 | 76 | **IMPROVEMENTS:** 77 | 78 | - New field implemented 79 | - `AppMetadata`: `status` 80 | 81 | ### [3.2.0] 2023-09-03 82 | 83 | **IMPROVEMENTS:** 84 | 85 | - New fields implemented 86 | - `RenewalInfo`: `renewalDate` 87 | - `TransactionInfo`: `storefront`, `storefrontId`, `transactionReason` 88 | 89 | ### [3.1.1] 2023-09-03 90 | 91 | **BUGFIX:** 92 | 93 | - `ResponseBodyV2`: `createFromRawNotification()` fix, now it checks incoming notification to be not only a valid JSON, but also to be an array 94 | 95 | ### [3.1.0] 2023-08-26 96 | 97 | **BUGFIX:** 98 | 99 | - `ASN1SequenceOfInteger`: math fixes 100 | - `StatusResponse`: `data` array initialization with `[]` 101 | 102 | **IMPROVEMENTS:** 103 | 104 | - `HTTPRequest`: PUT method added; HTTP method and URL added to `HTTPRequestFailed` exception message 105 | - `JWT`: additional information in exception message 106 | 107 | ### [3.0.1] 2023-08-23 108 | 109 | **BUGFIX:** 110 | 111 | - Math bug fixed in `ASN1SequenceOfInteger`. In rare cases signature was calculated in a wrong way which led to `Wrong signature` exception in `JWT::verifySignature` 112 | 113 | ### [3.0.0] 2023-08-18 114 | 115 | ***BREAKING CHANGES:*** 116 | 117 | - Main classes renamed: 118 | - `APIClient` -> `AppStoreServerAPI` 119 | - `APIClientInterface` -> `AppStoreServerAPIInterface` 120 | - `Notification\ResponseBodyV2` -> `ResponseBodyV2` 121 | - `JWT` -> `Util\JWT` 122 | - `Request\GetTransactionHistory` -> `Request\GetTransactionHistoryRequest` 123 | - `Request\RequestTestNotification` -> `Request\RequestTestNotificationRequest` 124 | - `Request\GetTransactionHistoryQueryParams` -> `RequestQueryParams\GetTransactionHistoryQueryParams` 125 | - Environment consts moved out from all classes to the separate class `Environment` 126 | - `getTransactionHistory()` method signature changed: it no longer expects for QueryParams instance as a second arguments, now it expects array instead 127 | - `AppStoreServerAPI` (previously `APIClient`) constructor signature changed: 128 | - `$environment` argument type changed from int to string 129 | - `$keyId` and `$key` arguments swapped 130 | 131 | **IMPROVEMENTS:** 132 | 133 | - PHP 7.4 support out of the box ;) 134 | - A lot of new endpoints (see [README](https://github.com/readdle/app-store-server-api/blob/master/README.md)) 135 | - Examples for all implemented endpoints (and notification listener) 136 | -------------------------------------------------------------------------------- /src/Request/AbstractRequestParamsBag.php: -------------------------------------------------------------------------------- 1 | 36 | */ 37 | protected array $requiredFields = []; 38 | 39 | /** 40 | * @param array $params 41 | */ 42 | public function __construct(array $params = []) 43 | { 44 | $reflection = new ReflectionClass($this); 45 | 46 | $protectedProps = array_filter( 47 | $reflection->getProperties(), 48 | fn ($property) => $property->isProtected() && $property->getName() !== 'requiredFields' 49 | ); 50 | $protectedProps = array_combine(array_map(fn ($p) => $p->getName(), $protectedProps), $protectedProps); 51 | 52 | $requiredFields = $this->requiredFields; 53 | 54 | if (in_array('*', $requiredFields)) { 55 | $requiredFields = array_keys($protectedProps); 56 | } 57 | 58 | $diff = array_diff($requiredFields, array_keys($params)); 59 | 60 | if ($diff) { 61 | throw new Error(vsprintf( 62 | '[%s] Required fields are missing: ["' . join('","', $diff) . '"]', 63 | [get_class($this)] 64 | )); 65 | } 66 | 67 | $propConsts = array_filter( 68 | $reflection->getConstants(), 69 | fn ($const) => strpos($const, '__') !== false, 70 | ARRAY_FILTER_USE_KEY 71 | ); 72 | 73 | /** @var array $propValues */ 74 | $propValues = []; 75 | 76 | foreach ($propConsts as $constName => $constValue) { 77 | $camelPropName = lcfirst(join(array_map( 78 | fn ($part) => ucfirst(strtolower($part)), 79 | explode('_', explode('__', $constName)[0]) 80 | ))); 81 | $propValues[$camelPropName][] = $constValue; 82 | } 83 | 84 | foreach ($params as $name => $value) { 85 | if ($name === 'revision') { 86 | throw new Error(vsprintf('[%s] Revision could not be set as a parameter', [get_class($this)])); 87 | } 88 | 89 | if (!array_key_exists($name, $protectedProps)) { 90 | throw new Error(vsprintf('[%s] Unrecognized parameter "%s"', [get_class($this), $name])); 91 | } 92 | 93 | if (!$this->isValueMatchingPropType($value, $protectedProps[$name])) { 94 | throw new TypeError(vsprintf( 95 | '[%s] Parameter "%s" is of wrong type "%s" ("%s" is expected)', 96 | [get_class($this), $name, gettype($value), $protectedProps[$name]->getType()] 97 | )); 98 | } 99 | 100 | if (isset($propValues[$name]) && !$this->isValueMatchingPropValues($value, $propValues[$name])) { 101 | throw new UnexpectedValueException(vsprintf( 102 | '[%s] Parameter "%s" has wrong value %s', 103 | [get_class($this), $name, var_export($value, true)] 104 | )); 105 | } 106 | 107 | $this->$name = $value; 108 | } 109 | } 110 | 111 | /** 112 | * @param mixed $value 113 | */ 114 | protected function isValueMatchingPropType($value, ReflectionProperty $prop): bool 115 | { 116 | /** @var ReflectionNamedType $propType */ 117 | $propType = $prop->getType(); 118 | $propTypeName = $propType->getName(); 119 | 120 | switch ($propTypeName) { 121 | case 'int': 122 | $propTypeName = 'integer'; 123 | break; 124 | 125 | case 'bool': 126 | $propTypeName = 'boolean'; 127 | break; 128 | } 129 | 130 | if ($propTypeName === 'array') { 131 | // we don't know what type of value it should be 132 | // probably, the following check for value will do the work 133 | return true; 134 | } 135 | 136 | if (!is_scalar($value)) { 137 | // if the prop's type is not 'array' the value type should match it and, thus, it can't be not scalar 138 | return false; 139 | } 140 | 141 | return $propTypeName === gettype($value); 142 | } 143 | 144 | /** 145 | * @param mixed $value 146 | * @param array $propValues 147 | */ 148 | protected function isValueMatchingPropValues($value, array $propValues): bool 149 | { 150 | if (is_scalar($value)) { 151 | return in_array($value, $propValues); 152 | } 153 | 154 | if (is_array($value)) { 155 | return !!array_filter($value, fn ($v) => !in_array($v, $propValues)); 156 | } 157 | 158 | return false; 159 | } 160 | 161 | /** 162 | * @return array 163 | */ 164 | protected function collectProps(): array 165 | { 166 | $props = []; 167 | 168 | $reflection = new ReflectionClass($this); 169 | $protectedProps = array_filter($reflection->getProperties(), fn ($property) => $property->isProtected()); 170 | 171 | foreach ($protectedProps as $prop) { 172 | $propName = $prop->getName(); 173 | $value = $this->$propName; 174 | $defaultValue = $prop->getDeclaringClass()->getDefaultProperties()[$propName] ?? null; 175 | 176 | if (!isset($value) || $value === $defaultValue) { 177 | continue; 178 | } 179 | 180 | if (is_array($value)) { 181 | $props[$propName] = array_merge($props[$propName] ?? [], $value); 182 | } else { 183 | $props[$propName] = $value; 184 | } 185 | } 186 | 187 | return $props; 188 | 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/AppStoreServerAPIInterface.php: -------------------------------------------------------------------------------- 1 | $queryParams [optional] Query Parameters 36 | * 37 | * @throws AppStoreServerAPIException 38 | */ 39 | public function getTransactionHistory(string $transactionId, array $queryParams = []): HistoryResponse; 40 | 41 | /** 42 | * Get information about a single transaction for your app. 43 | * 44 | * @param string $transactionId The identifier of a transaction that belongs to the customer, and which may be an 45 | * original transaction identifier 46 | * 47 | * @throws AppStoreServerAPIException 48 | */ 49 | public function getTransactionInfo(string $transactionId): TransactionInfoResponse; 50 | 51 | /** 52 | * Get the statuses for all of a customer's auto-renewable subscriptions in your app. 53 | * 54 | * @param string $transactionId The identifier of a transaction that belongs to the customer, and which may be an 55 | * original transaction identifier 56 | * @param array $queryParams [optional] Query Parameters 57 | * 58 | * @throws AppStoreServerAPIException 59 | */ 60 | public function getAllSubscriptionStatuses(string $transactionId, array $queryParams = []): StatusResponse; 61 | 62 | /** 63 | * Send consumption information about a consumable in-app purchase to the App Store after your server receives 64 | * a consumption request notification. 65 | * 66 | * @param string $transactionId The transaction identifier for which you’re providing consumption information. 67 | * You receive this identifier in the CONSUMPTION_REQUEST notification the App Store sends to your server. 68 | * @param array $requestBody The request body containing consumption information. 69 | * 70 | * @throws AppStoreServerAPIException 71 | */ 72 | public function sendConsumptionInformation(string $transactionId, array $requestBody): void; 73 | 74 | /** 75 | * Get a customer's in-app purchases from a receipt using the order ID. 76 | * 77 | * @param string $orderId The order ID for in-app purchases that belong to the customer. 78 | * 79 | * @throws AppStoreServerAPIException 80 | */ 81 | public function lookUpOrderId(string $orderId): OrderLookupResponse; 82 | 83 | /** 84 | * Get a list of all of a customer's refunded in-app purchases for your app. 85 | * 86 | * @param string $transactionId The identifier of a transaction that belongs to the customer, and which may be an 87 | * original transaction identifier 88 | * 89 | * @throws AppStoreServerAPIException 90 | */ 91 | public function getRefundHistory(string $transactionId): RefundHistoryResponse; 92 | 93 | /** 94 | * Extends the renewal date of a customer’s active subscription using the original transaction identifier. 95 | * 96 | * @param string $originalTransactionId The original transaction identifier of the subscription receiving a renewal 97 | * date extension. 98 | * @param array $requestBody The request body that contains subscription-renewal-extension 99 | * data for an individual subscription. 100 | * 101 | * @throws AppStoreServerAPIException 102 | */ 103 | public function extendSubscriptionRenewalDate( 104 | string $originalTransactionId, 105 | array $requestBody 106 | ): ExtendRenewalDateResponse; 107 | 108 | /** 109 | * Uses a subscription’s product identifier to extend the renewal date for all of its eligible active subscribers. 110 | * 111 | * @param array $requestBody 112 | * 113 | * @throws AppStoreServerAPIException 114 | */ 115 | public function massExtendSubscriptionRenewalDate(array $requestBody): MassExtendRenewalDateResponse; 116 | 117 | /** 118 | * Checks whether a renewal date extension request completed, and provides the final count of successful 119 | * or failed extensions. 120 | * 121 | * @param string $productId The product identifier of the auto-renewable subscription that you request 122 | * a renewal-date extension for. 123 | * @param string $requestIdentifier The UUID that represents your request to 124 | * the massExtendSubscriptionRenewalDate() endpoint. 125 | * 126 | * @throws AppStoreServerAPIException 127 | */ 128 | public function getStatusOfSubscriptionRenewalDateExtensionsRequest( 129 | string $productId, 130 | string $requestIdentifier 131 | ): MassExtendRenewalDateStatusResponse; 132 | 133 | /** 134 | * Get a list of notifications that the App Store server attempted to send to your server. 135 | * 136 | * @param array $requestBody The request body that includes the start and end dates, 137 | * and optional query constraints. 138 | * 139 | * @throws AppStoreServerAPIException 140 | */ 141 | public function getNotificationHistory(array $requestBody): NotificationHistoryResponse; 142 | 143 | /** 144 | * Ask App Store Server Notifications to send a test notification to your server 145 | * 146 | * @throws AppStoreServerAPIException 147 | */ 148 | public function requestTestNotification(): SendTestNotificationResponse; 149 | 150 | /** 151 | * Check the status of the test App Store server notification sent to your server. 152 | * 153 | * @param string $testNotificationToken The token that uniquely identifies a test, that you receive when you call 154 | * APIClientInterface::requestTestNotification(). 155 | * 156 | * @throws AppStoreServerAPIException 157 | */ 158 | public function getTestNotificationStatus(string $testNotificationToken): CheckTestNotificationResponse; 159 | } 160 | -------------------------------------------------------------------------------- /src/Util/JWT.php: -------------------------------------------------------------------------------- 1 | 'ES256', 37 | 'opensslAlgorithm' => OPENSSL_ALGO_SHA256, 38 | 'hashAlgorithm' => 'sha256', 39 | ]; 40 | 41 | const HEADER_TYP = 'typ'; 42 | const HEADER_ALG = 'alg'; 43 | const HEADER_KID = 'kid'; 44 | const HEADER_X5C = 'x5c'; 45 | 46 | const REQUIRED_JWT_HEADERS = [ 47 | self::HEADER_ALG, 48 | self::HEADER_X5C, 49 | ]; 50 | 51 | private static bool $unsafeMode = false; 52 | 53 | /** 54 | * @throws JWTCreationException 55 | * @throws Exception 56 | */ 57 | public static function createFrom(Key $key, Payload $payload): string 58 | { 59 | $header = [ 60 | self::HEADER_TYP => self::TYPE, 61 | self::HEADER_ALG => self::ALGORITHM['name'], 62 | self::HEADER_KID => $key->getKeyId(), 63 | ]; 64 | 65 | $segments[] = Helper::base64Encode(json_encode($header)); 66 | $segments[] = Helper::base64Encode(json_encode($payload->toArray())); 67 | 68 | $error = null; 69 | $errorHandler = function (int $code, string $message) use (&$error): bool { 70 | if (preg_match('/^openssl_sign\(\): ([\w\s]+)$/', trim($message), $m)) { 71 | $error = $m[1]; 72 | } 73 | 74 | return true; 75 | }; 76 | 77 | $previousErrorHandler = set_error_handler($errorHandler); 78 | $signatureAsASN1 = ''; 79 | $isSigned = openssl_sign( 80 | join('.', $segments), 81 | $signatureAsASN1, 82 | $key->getKey(), 83 | self::ALGORITHM['opensslAlgorithm'] 84 | ); 85 | set_error_handler($previousErrorHandler); 86 | 87 | if (!$isSigned) { 88 | throw new JWTCreationException('Message could not be signed', new Exception($error)); 89 | } 90 | 91 | try { 92 | $signature = ASN1SequenceOfInteger::toHex($signatureAsASN1); 93 | } catch (Exception $e) { 94 | throw new JWTCreationException('Signature could not be encoded', $e); 95 | } 96 | 97 | $segments[] = Helper::base64Encode(hex2bin($signature)); 98 | return join('.', $segments); 99 | } 100 | 101 | /** 102 | * WARNING! 103 | * THIS METHOD TURNS OFF VALIDATION AT ALL! 104 | * USE IT FOR TESTING PURPOSES ONLY! 105 | * 106 | * Turns unsafe mode on/off. 107 | * Returns previous state of unsafe mode. 108 | * 109 | * NOTE: Unsafe mode means that payloads will be parsed without any validation. 110 | * 111 | * @noinspection PhpUnused 112 | */ 113 | public static function unsafeMode(bool $state = true): bool 114 | { 115 | $previousState = self::$unsafeMode; 116 | self::$unsafeMode = $state; 117 | return $previousState; 118 | } 119 | 120 | /** 121 | * Parses signed payload, checks its headers, certificates chain, signature. 122 | * Returns decoded payload. 123 | * Throws an exception if payload is malformed or verification failed. 124 | * 125 | * @return array 126 | * 127 | * @throws MalformedJWTException 128 | */ 129 | public static function parse(string $jwt, ?string $rootCertificate = null): array 130 | { 131 | $parts = explode('.', $jwt); 132 | $partsCount = count($parts); 133 | 134 | if ($partsCount !== 3) { 135 | throw new MalformedJWTException('Payload should contain 3 parts, ' . $partsCount . ' were found'); 136 | } 137 | 138 | [$headersJson, $payloadJson, $signature] = array_map([Helper::class, 'base64Decode'], $parts); 139 | 140 | if (!$headersJson || !$payloadJson || !$signature) { 141 | throw new MalformedJWTException('JWT could not be decoded'); 142 | } 143 | 144 | $headers = json_decode($headersJson, true); 145 | 146 | if (json_last_error() !== JSON_ERROR_NONE) { 147 | throw new MalformedJWTException('Headers JSON could not be decoded'); 148 | } 149 | 150 | $payload = json_decode($payloadJson, true); 151 | 152 | if (json_last_error() !== JSON_ERROR_NONE) { 153 | throw new MalformedJWTException('Payload JSON could not be decoded'); 154 | } 155 | 156 | if (self::$unsafeMode) { 157 | return $payload; 158 | } 159 | 160 | $missingHeaders = []; 161 | 162 | foreach (self::REQUIRED_JWT_HEADERS as $headerName) { 163 | if (!array_key_exists($headerName, $headers)) { 164 | $missingHeaders[] = $headerName; 165 | } 166 | } 167 | 168 | if ($missingHeaders) { 169 | throw new MalformedJWTException('Required headers are missing: [' . join(', ', $missingHeaders) . ']'); 170 | } 171 | 172 | if ($headers[self::HEADER_ALG] !== self::ALGORITHM['name']) { 173 | throw new MalformedJWTException('Unrecognized algorithm: ' . $headers[self::HEADER_ALG]); 174 | } 175 | 176 | try { 177 | self::verifyX509Chain($headers[self::HEADER_X5C], $rootCertificate); 178 | } catch (Exception $e) { 179 | throw new MalformedJWTException('Certificate chain could not be verified: ' . $e->getMessage()); 180 | } 181 | 182 | $signedPart = substr($jwt, 0, strrpos($jwt, '.')); 183 | 184 | try { 185 | self::verifySignature($headers, $signedPart, $signature); 186 | } catch (Exception $e) { 187 | throw new MalformedJWTException('Signature verification failed: ' . $e->getMessage()); 188 | } 189 | 190 | return $payload; 191 | } 192 | 193 | /** 194 | * @param array $chain 195 | * 196 | * @throws Exception 197 | */ 198 | private static function verifyX509Chain(array $chain, ?string $rootCertificate = null): void 199 | { 200 | [$certificate, $intermediate, $root] = array_map([Helper::class, 'formatPEM'], $chain); 201 | 202 | if (openssl_x509_verify($certificate, $intermediate) !== 1) { 203 | throw new Exception('Certificate verification failed'); 204 | } 205 | 206 | if (openssl_x509_verify($intermediate, $root) !== 1) { 207 | throw new Exception('Intermediate certificate verification failed'); 208 | } 209 | 210 | if ( 211 | !is_null($rootCertificate) 212 | && openssl_x509_verify($root, Helper::formatPEM($rootCertificate)) !== 1 213 | ) { 214 | throw new Exception('Root certificate verification failed'); 215 | } 216 | } 217 | 218 | /** 219 | * @param array $headers 220 | * 221 | * @throws Exception 222 | */ 223 | private static function verifySignature(array $headers, string $input, string $signature): void 224 | { 225 | $signatureAsASN1 = ASN1SequenceOfInteger::fromHex(bin2hex($signature)); 226 | $publicKey = Helper::formatPEM($headers[self::HEADER_X5C][0]); 227 | 228 | if (openssl_verify($input, $signatureAsASN1, $publicKey, self::ALGORITHM['hashAlgorithm']) !== 1) { 229 | throw new Exception('Wrong signature'); 230 | } 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # About 2 | 3 | This is a ***zero-dependencies\* pure PHP*** library that allows managing customer transactions using the [`App Store Server API`](https://developer.apple.com/documentation/appstoreserverapi) and handling server-to-server notifications by providing everything you need to implement the [`App Store Server Notifications V2`](https://developer.apple.com/documentation/appstoreservernotifications) endpoint. 4 | 5 | * Zero-dependencies means this library doesn't rely on any third-party library. At the same time, this library relies on such essential PHP extensions as `json` and `openssl` 6 | 7 | > **NOTE** 8 | > 9 | > If you need to deal with receipts instead of (or additionally to) API, check out [this library](https://github.com/readdle/app-store-receipt-verification). 10 | 11 | # Installation 12 | 13 | Nothing special here, just use composer to install the package: 14 | 15 | > composer install readdle/app-store-server-api 16 | 17 | # Usage 18 | 19 | ### App Store Server API 20 | 21 | API initialization: 22 | 23 | ``` 24 | try { 25 | $api = new \Readdle\AppStoreServerAPI\AppStoreServerAPI( 26 | \Readdle\AppStoreServerAPI\Environment::PRODUCTION, 27 | '1a2b3c4d-1234-4321-1111-1a2b3c4d5e6f', 28 | 'com.readdle.MyBundle', 29 | 'ABC1234DEF', 30 | "-----BEGIN PRIVATE KEY-----\n\n-----END PRIVATE KEY-----" 31 | ); 32 | } catch (\Readdle\AppStoreServerAPI\Exception\WrongEnvironmentException $e) { 33 | exit($e->getMessage()); 34 | } 35 | ``` 36 | 37 | Performing API call: 38 | 39 | ``` 40 | try { 41 | $transactionHistory = $api->getTransactionHistory($transactionId, ['sort' => GetTransactionHistoryQueryParams::SORT__DESCENDING]); 42 | $transactions = $transactionHistory->getTransactions(); 43 | } catch (\Readdle\AppStoreServerAPI\Exception\AppStoreServerAPIException $e) { 44 | exit($e->getMessage()); 45 | } 46 | ``` 47 | 48 | ### App Store Server Notifications 49 | 50 | ``` 51 | try { 52 | $responseBodyV2 = \Readdle\AppStoreServerAPI\ResponseBodyV2::createFromRawNotification( 53 | '{"signedPayload":"..."}', 54 | \Readdle\AppStoreServerAPI\Util\Helper::toPEM(file_get_contents('https://www.apple.com/certificateauthority/AppleRootCA-G3.cer')) 55 | ); 56 | } catch (\Readdle\AppStoreServerAPI\Exception\AppStoreServerNotificationException $e) { 57 | exit('Server notification could not be processed: ' . $e->getMessage()); 58 | } 59 | ``` 60 | 61 | # Examples 62 | 63 | In `examples/` directory you can find examples for all implemented endpoints. Initialization of the API client is separated into `client.php` and used in all examples. 64 | 65 | In order to run examples you have to create `credentials.json` and/or `notifications.json` inside `examples/` directory. 66 | 67 | `credentials.json` structure should be as follows: 68 | 69 | ``` 70 | { 71 | "env": "Production", 72 | "issuerId": "1a2b3c4d-1234-4321-1111-1a2b3c4d5e6f", 73 | "bundleId": "com.readdle.MyBundle", 74 | "keyId": "ABC1234DEF", 75 | "key": "-----BEGIN PRIVATE KEY-----\n\n-----END PRIVATE KEY-----", 76 | "orderId": "ABC1234DEF", 77 | "transactionId": "123456789012345" 78 | } 79 | ``` 80 | 81 | In most examples `transactionId` is used. Please, consider that `transactionId` is related to `environment`, so if you put `transactionId` from the sandbox the `environment` property should be `Sandbox` as well, otherwise you'll get `{"errorCode":4040010,"errorMessage":"Transaction id not found."}` error. 82 | 83 | For `Order ID lookup` you have to specify `orderId`. This endpoint (and, consequently, the example) is not available in the sandbox environment. 84 | 85 | `notification.json` structure is the same as you receive it in your server-to-server notification endpoint: 86 | 87 | ``` 88 | {"signedPayload":""} 89 | ``` 90 | 91 | # What is covered 92 | 93 | ### In-app purchase history 94 | 95 | #### [Get Transaction History](https://developer.apple.com/documentation/appstoreserverapi/get_transaction_history) 96 | 97 | `AppStoreServerAPI::getTransactionHistory(string $transactionId, array $queryParams)` 98 | 99 | Get a customer’s in-app purchase transaction history for your app. 100 | 101 | ### Transaction Info 102 | 103 | #### [Get Transaction Info](https://developer.apple.com/documentation/appstoreserverapi/get_transaction_info) 104 | 105 | `AppStoreServerAPI::getTransactionInfo(string $transactionId)` 106 | 107 | Get information about a single transaction for your app. 108 | 109 | ### Subscription status 110 | 111 | #### [Get All Subscription Statuses](https://developer.apple.com/documentation/appstoreserverapi/get_all_subscription_statuses) 112 | 113 | `AppStoreServerAPI::getAllSubscriptionStatuses(string $transactionId, array $queryParams = [])` 114 | 115 | Get the statuses for all of a customer’s auto-renewable subscriptions in your app. 116 | 117 | ### Consumption information 118 | 119 | #### [Send Consumption Information](https://developer.apple.com/documentation/appstoreserverapi/send_consumption_information) 120 | 121 | `AppStoreServerAPI::sendConsumptionInformation(string $transactionId, array $requestBody)` 122 | 123 | Send consumption information about a consumable in-app purchase to the App Store after your server receives a consumption request notification. 124 | 125 | ### Order ID lookup 126 | 127 | #### [Look Up Order ID](https://developer.apple.com/documentation/appstoreserverapi/look_up_order_id) 128 | 129 | `AppStoreServerAPI::lookUpOrderId(string $orderId)` 130 | 131 | Get a customer’s in-app purchases from a receipt using the order ID. 132 | 133 | ### Refund lookup 134 | 135 | #### [Get Refund History](https://developer.apple.com/documentation/appstoreserverapi/get_refund_history) 136 | 137 | `AppStoreServerAPI::getRefundHistory(string $transactionId)` 138 | 139 | Get a list of all of a customer’s refunded in-app purchases for your app. 140 | 141 | ### Subscription-renewal-date extension 142 | 143 | #### [Extend a Subscription Renewal Date](https://developer.apple.com/documentation/appstoreserverapi/extend_a_subscription_renewal_date) 144 | 145 | `AppStoreServerAPI::extendSubscriptionRenewalDate(string $originalTransactionId, array $requestBody)` 146 | 147 | Extends the renewal date of a customer’s active subscription using the original transaction identifier. 148 | 149 | #### [Extend Subscription Renewal Dates for All Active Subscribers](https://developer.apple.com/documentation/appstoreserverapi/extend_subscription_renewal_dates_for_all_active_subscribers) 150 | 151 | `AppStoreServerAPI::massExtendSubscriptionRenewalDate(array $requestBody)` 152 | 153 | Uses a subscription’s product identifier to extend the renewal date for all of its eligible active subscribers. 154 | 155 | #### [Get Status of Subscription Renewal Date Extensions](https://developer.apple.com/documentation/appstoreserverapi/get_status_of_subscription_renewal_date_extensions) 156 | 157 | `AppStoreServerAPI::getStatusOfSubscriptionRenewalDateExtensionsRequest(string $productId, string $requestIdentifier)` 158 | 159 | Checks whether a renewal date extension request completed, and provides the final count of successful or failed extensions. 160 | 161 | ### App Store Server Notifications history 162 | 163 | #### [Get Notification History](https://developer.apple.com/documentation/appstoreserverapi/get_notification_history) 164 | 165 | `AppStoreServerAPI::getNotificationHistory(array $requestBody)` 166 | 167 | Get a list of notifications that the App Store server attempted to send to your server. 168 | 169 | ### App Store Server Notifications testing 170 | 171 | #### [Request a Test Notification](https://developer.apple.com/documentation/appstoreserverapi/request_a_test_notification) 172 | 173 | `AppStoreServerAPI::requestTestNotification()` 174 | 175 | Ask App Store Server Notifications to send a test notification to your server. 176 | 177 | #### [Get Test Notification Status](https://developer.apple.com/documentation/appstoreserverapi/get_test_notification_status) 178 | 179 | `AppStoreServerAPI::getTestNotificationStatus(string $testNotificationToken)` 180 | 181 | Check the status of the test App Store server notification sent to your server. 182 | -------------------------------------------------------------------------------- /src/RequestBody/ConsumptionRequestBody.php: -------------------------------------------------------------------------------- 1 | $rawRenewalInfo 159 | */ 160 | public static function createFromRawRenewalInfo(array $rawRenewalInfo): self 161 | { 162 | $renewalInfo = new self(); 163 | $typeCaster = Helper::arrayTypeCastGenerator($rawRenewalInfo, [ 164 | 'int' => [ 165 | 'autoRenewStatus', 'expirationIntent', 'gracePeriodExpiresDate', 'offerType', 166 | 'priceIncreaseStatus', 'recentSubscriptionStartDate', 'renewalDate', 'signedDate', 167 | ], 168 | 'bool' => [ 169 | 'isInBillingRetryPeriod', 170 | ], 171 | 'string' => [ 172 | 'autoRenewProductId', 'environment', 'offerIdentifier', 173 | 'originalTransactionId', 'productId', 174 | ], 175 | ]); 176 | 177 | foreach ($typeCaster as $prop => $value) { 178 | $renewalInfo->$prop = $value; 179 | } 180 | 181 | return $renewalInfo; 182 | } 183 | 184 | /** 185 | * Returns the product identifier of the product that renews at the next billing period. 186 | */ 187 | public function getAutoRenewProductId(): string 188 | { 189 | return $this->autoRenewProductId; 190 | } 191 | 192 | /** 193 | * Returns the renewal status for an auto-renewable subscription. 194 | * 195 | * @return self::AUTO_RENEW_STATUS__* 196 | */ 197 | public function getAutoRenewStatus(): int 198 | { 199 | /** @phpstan-ignore-next-line */ 200 | return $this->autoRenewStatus; 201 | } 202 | 203 | /** 204 | * Returns the server environment, either sandbox or production. 205 | * 206 | * @return Environment::PRODUCTION|Environment::SANDBOX 207 | */ 208 | public function getEnvironment(): string 209 | { 210 | return $this->environment; 211 | } 212 | 213 | /** 214 | * Returns the reason a subscription expired (if any). 215 | * 216 | * @return null|self::EXPIRATION_INTENT__* 217 | */ 218 | public function getExpirationIntent(): ?int 219 | { 220 | /** @phpstan-ignore-next-line */ 221 | return $this->expirationIntent; 222 | } 223 | 224 | /** 225 | * Returns the time when the billing grace period for subscription renewals expires (if any). 226 | */ 227 | public function getGracePeriodExpiresDate(): ?int 228 | { 229 | return $this->gracePeriodExpiresDate; 230 | } 231 | 232 | /** 233 | * Returns the Boolean value that indicates whether the App Store is attempting to automatically renew an expired 234 | * subscription. 235 | */ 236 | public function getIsInBillingRetryPeriod(): ?bool 237 | { 238 | return $this->isInBillingRetryPeriod; 239 | } 240 | 241 | /** 242 | * Returns the offer code or the promotional offer identifier (if any). 243 | */ 244 | public function getOfferIdentifier(): ?string 245 | { 246 | return $this->offerIdentifier; 247 | } 248 | 249 | /** 250 | * Returns the type of subscription offer (if any). 251 | * 252 | * @return self::OFFER_TYPE__* 253 | */ 254 | public function getOfferType(): ?int 255 | { 256 | /** @phpstan-ignore-next-line */ 257 | return $this->offerType; 258 | } 259 | 260 | /** 261 | * Returns the original transaction identifier of a purchase. 262 | */ 263 | public function getOriginalTransactionId(): string 264 | { 265 | return $this->originalTransactionId; 266 | } 267 | 268 | /** 269 | * Returns the status that indicates whether the auto-renewable subscription is subject to a price increase (if any) 270 | * 271 | * @return self::PRICE_INCREASE_STATUS__* 272 | */ 273 | public function getPriceIncreaseStatus(): ?int 274 | { 275 | /** @phpstan-ignore-next-line */ 276 | return $this->priceIncreaseStatus; 277 | } 278 | 279 | /** 280 | * Returns the product identifier of the in-app purchase. 281 | */ 282 | public function getProductId(): string 283 | { 284 | return $this->productId; 285 | } 286 | 287 | /** 288 | * Returns the earliest start date of an auto-renewable subscription in a series of subscription purchases 289 | * that ignores all lapses of paid service that are 60 days or less. 290 | */ 291 | public function getRecentSubscriptionStartDate(): int 292 | { 293 | return $this->recentSubscriptionStartDate; 294 | } 295 | 296 | /** 297 | * Returns the UNIX time, in milliseconds, that the most recent auto-renewable subscription purchase expires. 298 | */ 299 | public function getRenewalDate(): ?int 300 | { 301 | return $this->renewalDate; 302 | } 303 | 304 | /** 305 | * Returns the UNIX time, in milliseconds, that the App Store signed the JSON Web Signature data. 306 | */ 307 | public function getSignedDate(): int 308 | { 309 | return $this->signedDate; 310 | } 311 | 312 | /** 313 | * @return array 314 | */ 315 | public function jsonSerialize(): array 316 | { 317 | return get_object_vars($this); 318 | } 319 | } 320 | -------------------------------------------------------------------------------- /src/AppStoreServerAPI.php: -------------------------------------------------------------------------------- 1 | environment = $environment; 72 | $this->payload = new Payload($issuerId, $bundleId); 73 | $this->key = new Key($keyId, $key); 74 | } 75 | 76 | public function getTransactionHistory(string $transactionId, array $queryParams = []): HistoryResponse 77 | { 78 | /** 79 | * @var HistoryResponse $response 80 | */ 81 | $response = $this->performRequest( 82 | GetTransactionHistoryRequest::class, 83 | HistoryResponse::class, 84 | ['transactionId' => $transactionId], 85 | new GetTransactionHistoryQueryParams($queryParams) 86 | ); 87 | return $response; 88 | } 89 | 90 | public function getTransactionInfo(string $transactionId): TransactionInfoResponse 91 | { 92 | /** 93 | * @var TransactionInfoResponse $response 94 | */ 95 | $response = $this->performRequest( 96 | GetTransactionInfoRequest::class, 97 | TransactionInfoResponse::class, 98 | ['transactionId' => $transactionId], 99 | ); 100 | return $response; 101 | } 102 | 103 | public function getAllSubscriptionStatuses(string $transactionId, array $queryParams = []): StatusResponse 104 | { 105 | /** 106 | * @var StatusResponse $response 107 | */ 108 | $response = $this->performRequest( 109 | GetAllSubscriptionStatusesRequest::class, 110 | StatusResponse::class, 111 | ['transactionId' => $transactionId], 112 | new GetAllSubscriptionStatusesQueryParams($queryParams) 113 | ); 114 | return $response; 115 | } 116 | 117 | public function sendConsumptionInformation(string $transactionId, array $requestBody): void 118 | { 119 | $this->performRequest( 120 | SendConsumptionInformationRequest::class, 121 | null, 122 | ['transactionId' => $transactionId], 123 | null, 124 | new ConsumptionRequestBody($requestBody) 125 | ); 126 | } 127 | 128 | public function lookUpOrderId(string $orderId): OrderLookupResponse 129 | { 130 | /** 131 | * @var OrderLookupResponse $response 132 | */ 133 | $response = $this->performRequest( 134 | LookUpOrderIdRequest::class, 135 | OrderLookupResponse::class, 136 | ['orderId' => $orderId], 137 | ); 138 | return $response; 139 | } 140 | 141 | public function getRefundHistory(string $transactionId): RefundHistoryResponse 142 | { 143 | /** 144 | * @var RefundHistoryResponse $response 145 | */ 146 | $response = $this->performRequest( 147 | GetRefundHistoryRequest::class, 148 | RefundHistoryResponse::class, 149 | ['transactionId' => $transactionId], 150 | new GetRefundHistoryQueryParams() 151 | ); 152 | return $response; 153 | } 154 | 155 | /** 156 | * @inheritdoc 157 | * @throws Exception 158 | */ 159 | public function extendSubscriptionRenewalDate( 160 | string $originalTransactionId, 161 | array $requestBody 162 | ): ExtendRenewalDateResponse { 163 | /** 164 | * @var ExtendRenewalDateResponse $response 165 | */ 166 | $response = $this->performRequest( 167 | ExtendSubscriptionRenewalDateRequest::class, 168 | ExtendRenewalDateResponse::class, 169 | ['originalTransactionId' => $originalTransactionId], 170 | null, 171 | new ExtendRenewalDateRequestBody($requestBody) 172 | ); 173 | return $response; 174 | 175 | } 176 | 177 | /** 178 | * @inheritdoc 179 | * @throws Exception 180 | */ 181 | public function massExtendSubscriptionRenewalDate(array $requestBody): MassExtendRenewalDateResponse 182 | { 183 | /** 184 | * @var MassExtendRenewalDateResponse $response 185 | */ 186 | $response = $this->performRequest( 187 | MassExtendSubscriptionRenewalDateRequest::class, 188 | MassExtendRenewalDateResponse::class, 189 | [], 190 | null, 191 | new MassExtendRenewalDateRequestBody($requestBody) 192 | ); 193 | return $response; 194 | 195 | } 196 | 197 | public function getStatusOfSubscriptionRenewalDateExtensionsRequest( 198 | string $productId, 199 | string $requestIdentifier 200 | ): MassExtendRenewalDateStatusResponse { 201 | /** 202 | * @var MassExtendRenewalDateStatusResponse $response 203 | */ 204 | $response = $this->performRequest( 205 | GetStatusOfSubscriptionRenewalDateExtensionsRequest::class, 206 | MassExtendRenewalDateStatusResponse::class, 207 | [ 208 | 'productId' => $productId, 209 | 'requestIdentifier' => $requestIdentifier, 210 | ] 211 | ); 212 | return $response; 213 | } 214 | 215 | 216 | public function getNotificationHistory(array $requestBody): NotificationHistoryResponse 217 | { 218 | /** 219 | * @var NotificationHistoryResponse $response 220 | */ 221 | $response = $this->performRequest( 222 | GetNotificationHistoryRequest::class, 223 | NotificationHistoryResponse::class, 224 | [], 225 | new GetNotificationHistoryQueryParams(), 226 | new NotificationHistoryRequestBody($requestBody) 227 | ); 228 | return $response; 229 | } 230 | 231 | public function requestTestNotification(): SendTestNotificationResponse 232 | { 233 | /** 234 | * @var SendTestNotificationResponse $response 235 | */ 236 | $response = $this->performRequest(RequestTestNotificationRequest::class, SendTestNotificationResponse::class); 237 | return $response; 238 | } 239 | 240 | public function getTestNotificationStatus(string $testNotificationToken): CheckTestNotificationResponse 241 | { 242 | /** 243 | * @var CheckTestNotificationResponse $response 244 | */ 245 | $response = $this->performRequest( 246 | GetTestNotificationStatusRequest::class, 247 | CheckTestNotificationResponse::class, 248 | ['testNotificationToken' => $testNotificationToken], 249 | ); 250 | return $response; 251 | 252 | } 253 | 254 | private function createRequest( 255 | string $requestClass, 256 | ?AbstractRequestQueryParams $queryParams, 257 | ?AbstractRequestBody $body 258 | ): AbstractRequest { 259 | /** @var AbstractRequest $request */ 260 | $request = new $requestClass($this->key, $this->payload, $queryParams, $body); 261 | $request->setURLVars(['baseUrl' => $this->getBaseURL()]); 262 | return $request; 263 | } 264 | 265 | private function getBaseURL(): string 266 | { 267 | return $this->environment === Environment::PRODUCTION ? self::PRODUCTION_BASE_URL : self::SANDBOX_BASE_URL; 268 | } 269 | 270 | /** 271 | * @param array $requestUrlVars 272 | * 273 | * @throws HTTPRequestAborted 274 | * @throws HTTPRequestFailed 275 | * @throws InvalidImplementationException 276 | * @throws MalformedResponseException 277 | * @throws UnimplementedContentTypeException 278 | */ 279 | private function performRequest( 280 | string $requestClass, 281 | ?string $responseClass, 282 | array $requestUrlVars = [], 283 | ?AbstractRequestQueryParams $requestQueryParams = null, 284 | ?AbstractRequestBody $requestBody = null 285 | ): ?AbstractResponse { 286 | if ( 287 | !is_subclass_of($requestClass, AbstractRequest::class) 288 | || (!empty($responseClass) && !is_subclass_of($responseClass, AbstractResponse::class)) 289 | ) { 290 | throw new InvalidImplementationException($requestClass, $responseClass); 291 | } 292 | 293 | $request = $this->createRequest($requestClass, $requestQueryParams, $requestBody); 294 | 295 | if (!empty($requestUrlVars)) { 296 | $request->setURLVars($requestUrlVars); 297 | } 298 | 299 | $responseText = HTTPRequest::performRequest($request); 300 | 301 | if (empty($responseClass)) { 302 | return null; 303 | } 304 | 305 | return call_user_func([$responseClass, 'createFromString'], $responseText, $request); 306 | } 307 | } 308 | -------------------------------------------------------------------------------- /src/TransactionInfo.php: -------------------------------------------------------------------------------- 1 | $rawTransactionInfo 247 | */ 248 | public static function createFromRawTransactionInfo(array $rawTransactionInfo): self 249 | { 250 | $transactionInfo = new self(); 251 | $typeCaster = Helper::arrayTypeCastGenerator($rawTransactionInfo, [ 252 | 'int' => [ 253 | 'expiresDate', 'offerType', 'originalPurchaseDate', 'price', 'purchaseDate', 254 | 'quantity', 'revocationDate', 'revocationReason', 'signedDate', 255 | ], 256 | 'bool' => [ 257 | 'isUpgraded', 258 | ], 259 | 'string' => [ 260 | 'appAccountToken', 'bundleId', 'currency', 'environment', 'inAppOwnershipType', 261 | 'offerDiscountType', 'offerIdentifier', 'originalTransactionId', 'productId', 'storefront', 262 | 'storefrontId', 'subscriptionGroupIdentifier', 'transactionId', 'transactionReason', 263 | 'type', 'webOrderLineItemId', 264 | ], 265 | ]); 266 | 267 | foreach ($typeCaster as $prop => $value) { 268 | $transactionInfo->$prop = $value; 269 | } 270 | 271 | return $transactionInfo; 272 | } 273 | 274 | /** 275 | * Returns a UUID that associates the transaction with a user on your own service. 276 | * If the app doesn't provide an appAccountToken, this string is empty. 277 | */ 278 | public function getAppAccountToken(): ?string 279 | { 280 | return $this->appAccountToken; 281 | } 282 | 283 | /** 284 | * Returns the bundle identifier of the app. 285 | */ 286 | public function getBundleId(): string 287 | { 288 | return $this->bundleId; 289 | } 290 | 291 | /** 292 | * Returns the three-letter ISO 4217 currency code for the price of the product. 293 | */ 294 | public function getCurrency(): ?string 295 | { 296 | return $this->currency; 297 | } 298 | 299 | /** 300 | * Returns the server environment, either sandbox or production. 301 | * 302 | * @return Environment::PRODUCTION|Environment::SANDBOX 303 | */ 304 | public function getEnvironment(): string 305 | { 306 | return $this->environment; 307 | } 308 | 309 | /** 310 | * Returns the UNIX time, in milliseconds, the subscription expires or renews (if any). 311 | */ 312 | public function getExpiresDate(): ?int 313 | { 314 | return $this->expiresDate; 315 | } 316 | 317 | /** 318 | * Returns a string that describes whether the transaction was purchased by the user, or is available to them 319 | * through Family Sharing. 320 | * 321 | * @return self::IN_APP_OWNERSHIP_TYPE__* 322 | */ 323 | public function getInAppOwnershipType(): string 324 | { 325 | return $this->inAppOwnershipType; 326 | } 327 | 328 | /** 329 | * Returns a Boolean value that indicates whether the user upgraded to another subscription. 330 | */ 331 | public function getIsUpgraded(): ?bool 332 | { 333 | return $this->isUpgraded; 334 | } 335 | 336 | /** 337 | * Returns the identifier that contains the promo code or the promotional offer identifier. 338 | * 339 | * NOTE: This field applies only when the offerType is either promotional offer or subscription offer code. 340 | */ 341 | public function getOfferIdentifier(): ?string 342 | { 343 | return $this->offerIdentifier; 344 | } 345 | 346 | /** 347 | * Returns a value that represents the offer discount type (if any). 348 | * 349 | * @return null|self::OFFER_DISCOUNT_TYPE__* 350 | */ 351 | public function getOfferDiscountType(): ?string 352 | { 353 | /** @phpstan-ignore-next-line */ 354 | return $this->offerDiscountType; 355 | } 356 | 357 | /** 358 | * Returns a value that represents the promotional offer type (if any). 359 | * 360 | * @return null|self::OFFER_TYPE__* 361 | */ 362 | public function getOfferType(): ?int 363 | { 364 | /** @phpstan-ignore-next-line */ 365 | return $this->offerType; 366 | } 367 | 368 | /** 369 | * Returns the UNIX time, in milliseconds, that represents the purchase date of the original transaction identifier. 370 | */ 371 | public function getOriginalPurchaseDate(): int 372 | { 373 | return $this->originalPurchaseDate; 374 | } 375 | 376 | /** 377 | * Returns the transaction identifier of the original purchase. 378 | */ 379 | public function getOriginalTransactionId(): string 380 | { 381 | return $this->originalTransactionId; 382 | } 383 | 384 | /** 385 | * Returns the price multiplied by 1000 of the in-app purchase or subscription offer that you configured in App Store 386 | * Connect, as an integer. 387 | */ 388 | public function getPrice(): ?int 389 | { 390 | return $this->price; 391 | } 392 | 393 | /** 394 | * Returns the product identifier of the in-app purchase. 395 | */ 396 | public function getProductId(): string 397 | { 398 | return $this->productId; 399 | } 400 | 401 | /** 402 | * Returns the UNIX time, in milliseconds, that the App Store charged the user's account for a purchase, 403 | * restored product, subscription, or subscription renewal after a lapse. 404 | */ 405 | public function getPurchaseDate(): int 406 | { 407 | return $this->purchaseDate; 408 | } 409 | 410 | /** 411 | * Returns the number of consumable products the user purchased. 412 | */ 413 | public function getQuantity(): int 414 | { 415 | return $this->quantity; 416 | } 417 | 418 | /** 419 | * Returns the UNIX time, in milliseconds, that the App Store refunded the transaction or revoked it from 420 | * Family Sharing (if any). 421 | */ 422 | public function getRevocationDate(): ?int 423 | { 424 | return $this->revocationDate; 425 | } 426 | 427 | /** 428 | * The reason that the App Store refunded the transaction or revoked it from Family Sharing (if any). 429 | * 430 | * @return null|self::REVOCATION_REASON__* 431 | */ 432 | public function getRevocationReason(): ?int 433 | { 434 | /** @phpstan-ignore-next-line */ 435 | return $this->revocationReason; 436 | } 437 | 438 | /** 439 | * Returns the UNIX time, in milliseconds, that the App Store signed the JSON Web Signature (JWS) data. 440 | */ 441 | public function getSignedDate(): int 442 | { 443 | return $this->signedDate; 444 | } 445 | 446 | /** 447 | * Returns the three-letter code that represents the country or region associated with the App Store storefront for 448 | * the purchase. 449 | */ 450 | public function getStorefront(): ?string 451 | { 452 | return $this->storefront; 453 | } 454 | 455 | /** 456 | * Returns an Apple-defined value that uniquely identifies the App Store storefront associated with the purchase. 457 | */ 458 | public function getStorefrontId(): ?string 459 | { 460 | return $this->storefrontId; 461 | } 462 | 463 | /** 464 | * Returns the identifier of the subscription group the subscription belongs to (if any). 465 | */ 466 | public function getSubscriptionGroupIdentifier(): ?string 467 | { 468 | return $this->subscriptionGroupIdentifier; 469 | } 470 | 471 | /** 472 | * Returns the unique identifier of the transaction. 473 | */ 474 | public function getTransactionId(): string 475 | { 476 | return $this->transactionId; 477 | } 478 | 479 | /** 480 | * Returns the type of the in-app purchase. 481 | * 482 | * @return self::TRANSACTION_REASON__* 483 | */ 484 | public function getTransactionReason(): ?string 485 | { 486 | return $this->transactionReason; 487 | } 488 | 489 | /** 490 | * Returns the type of the in-app purchase. 491 | * 492 | * @return self::TYPE__* 493 | */ 494 | public function getType(): string 495 | { 496 | return $this->type; 497 | } 498 | 499 | /** 500 | * Returns the unique identifier of subscription purchase events across devices, including subscription 501 | * renewals (if any). 502 | */ 503 | public function getWebOrderLineItemId(): ?string 504 | { 505 | return $this->webOrderLineItemId; 506 | } 507 | 508 | /** 509 | * @return array 510 | */ 511 | public function jsonSerialize(): array 512 | { 513 | return get_object_vars($this); 514 | } 515 | } 516 | -------------------------------------------------------------------------------- /src/ResponseBodyV2.php: -------------------------------------------------------------------------------- 1 | getMessage()); 342 | } 343 | 344 | if (!empty($payload['data']['signedRenewalInfo'])) { 345 | try { 346 | $rawRenewalInfo = JWT::parse($payload['data']['signedRenewalInfo'], $rootCertificate); 347 | } catch (MalformedJWTException $e) { 348 | throw new AppStoreServerNotificationException('Malformed RenewalInfo JWT: ' . $e->getMessage()); 349 | } 350 | 351 | $payload['data']['renewalInfo'] = RenewalInfo::createFromRawRenewalInfo($rawRenewalInfo); 352 | unset($payload['data']['signedRenewalInfo']); 353 | } 354 | 355 | if (!empty($payload['data']['signedTransactionInfo'])) { 356 | try { 357 | $rawTransactionInfo = JWT::parse($payload['data']['signedTransactionInfo'], $rootCertificate); 358 | } catch (MalformedJWTException $e) { 359 | throw new AppStoreServerNotificationException('Malformed TransactionInfo JWT: ' . $e->getMessage()); 360 | } 361 | 362 | $payload['data']['transactionInfo'] = TransactionInfo::createFromRawTransactionInfo($rawTransactionInfo); 363 | unset($payload['data']['signedTransactionInfo']); 364 | } 365 | 366 | $responseBodyV2 = new self(); 367 | $responseBodyV2->appMetadata = AppMetadata::createFromRawData($payload['data']); 368 | 369 | $typeCaster = Helper::arrayTypeCastGenerator($payload, [ 370 | 'int' => ['signedDate'], 371 | 'string' => ['notificationType', 'subtype', 'notificationUUID', 'version'], 372 | ]); 373 | 374 | foreach ($typeCaster as $prop => $value) { 375 | $responseBodyV2->$prop = $value; 376 | } 377 | 378 | return $responseBodyV2; 379 | } 380 | 381 | /** 382 | * Returns description of the in-app purchase event that led to this notification. 383 | * 384 | * @return self::NOTIFICATION_TYPE__* 385 | */ 386 | public function getNotificationType(): string 387 | { 388 | return $this->notificationType; 389 | } 390 | 391 | /** 392 | * Returns additional information that identifies the notification event. 393 | * The subtype field is present only for specific version 2 notifications. 394 | * 395 | * @return null|self::SUBTYPE__* 396 | */ 397 | public function getSubtype(): ?string 398 | { 399 | return $this->subtype; 400 | } 401 | 402 | /** 403 | * Returns a unique identifier for the notification. 404 | */ 405 | public function getNotificationUUID(): string 406 | { 407 | return $this->notificationUUID; 408 | } 409 | 410 | /** 411 | * Returns the object that contains the app metadata and signed renewal and transaction information. 412 | */ 413 | public function getAppMetadata(): AppMetadata 414 | { 415 | return $this->appMetadata; 416 | } 417 | 418 | /** 419 | * Returns a string that indicates the App Store Server Notification version number. 420 | */ 421 | public function getVersion(): string 422 | { 423 | return $this->version; 424 | } 425 | 426 | /** 427 | * Returns the UNIX time, in milliseconds, that the App Store signed the JSON Web Signature data. 428 | */ 429 | public function getSignedDate(): int 430 | { 431 | return $this->signedDate; 432 | } 433 | 434 | /** 435 | * @return array 436 | */ 437 | public function jsonSerialize(): array 438 | { 439 | return get_object_vars($this); 440 | } 441 | } 442 | --------------------------------------------------------------------------------