├── .gitignore ├── .php-cs-fixer.dist.php ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── composer.json ├── composer.lock ├── examples ├── client.php ├── extendSubscriptionRenewalDate.php ├── getAllSubscriptionStatuses.php ├── getNotificationHistory.php ├── getRefundHistory.php ├── getStatusOfSubscriptionRenewalDateExtensionsRequest.php ├── getTransactionHistory.php ├── getTransactionHistoryV2.php ├── getTransactionInfo.php ├── helper.php ├── lookUpOrderId.php ├── massExtendSubscriptionRenewalDate.php ├── requestAndCheckTestNotification.php ├── sendConsumptionInformation.php └── serverNotificationV2.php ├── phpstan.neon ├── phpunit.xml.dist ├── src ├── AppMetadata.php ├── AppStoreServerAPI.php ├── AppStoreServerAPIInterface.php ├── Environment.php ├── Exception │ ├── AppStoreServerAPIException.php │ ├── AppStoreServerNotificationException.php │ ├── HTTPRequestAborted.php │ ├── HTTPRequestFailed.php │ ├── InvalidImplementationException.php │ ├── JWTCreationException.php │ ├── MalformedJWTException.php │ ├── MalformedResponseException.php │ ├── UnimplementedContentTypeException.php │ └── WrongEnvironmentException.php ├── HTTPRequest.php ├── Key.php ├── LastTransactionsItem.php ├── NotificationHistoryResponseItem.php ├── Payload.php ├── RenewalInfo.php ├── Request │ ├── AbstractRequest.php │ ├── AbstractRequestParamsBag.php │ ├── ExtendSubscriptionRenewalDateRequest.php │ ├── GetAllSubscriptionStatusesRequest.php │ ├── GetNotificationHistoryRequest.php │ ├── GetRefundHistoryRequest.php │ ├── GetStatusOfSubscriptionRenewalDateExtensionsRequest.php │ ├── GetTestNotificationStatusRequest.php │ ├── GetTransactionHistoryRequest.php │ ├── GetTransactionHistoryRequestV2.php │ ├── GetTransactionInfoRequest.php │ ├── LookUpOrderIdRequest.php │ ├── MassExtendSubscriptionRenewalDateRequest.php │ ├── RequestTestNotificationRequest.php │ └── SendConsumptionInformationRequest.php ├── RequestBody │ ├── AbstractRequestBody.php │ ├── ConsumptionRequestBody.php │ ├── ExtendRenewalDateRequestBody.php │ ├── MassExtendRenewalDateRequestBody.php │ └── NotificationHistoryRequestBody.php ├── RequestQueryParams │ ├── AbstractRequestQueryParams.php │ ├── GetAllSubscriptionStatusesQueryParams.php │ ├── GetNotificationHistoryQueryParams.php │ ├── GetRefundHistoryQueryParams.php │ ├── GetTransactionHistoryQueryParams.php │ └── PageableQueryParams.php ├── Response │ ├── AbstractResponse.php │ ├── CheckTestNotificationResponse.php │ ├── ExtendRenewalDateResponse.php │ ├── HistoryResponse.php │ ├── MassExtendRenewalDateResponse.php │ ├── MassExtendRenewalDateStatusResponse.php │ ├── NotificationHistoryResponse.php │ ├── OrderLookupResponse.php │ ├── PageableResponse.php │ ├── RefundHistoryResponse.php │ ├── SendTestNotificationResponse.php │ ├── StatusResponse.php │ └── TransactionInfoResponse.php ├── ResponseBodyV2.php ├── SendAttemptItem.php ├── SubscriptionGroupIdentifierItem.php ├── TransactionInfo.php └── Util │ ├── ASN1SequenceOfInteger.php │ ├── ArrayTypeCaseGenerator.php │ ├── Helper.php │ └── JWT.php └── tests ├── Unit └── Util │ └── ArrayTypeCaseGeneratorTest.php └── bootstrap.php /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .php-cs-fixer.cache 3 | .phpunit.result.cache 4 | examples/credentials.json 5 | examples/notification.json 6 | vendor/ 7 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.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 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### [3.12.0] 2025/05/22 2 | 3 | **IMPROVEMENTS:** 4 | 5 | - New `notificationType` = `ONE_TIME_CHARGE` in `ResponseBodyV2` 6 | 7 | ### [3.11.1] 2025/03/24 8 | 9 | **BUGFIX:** 10 | 11 | - `TransactionInfo::getAppTransactionId()` make it public (silly bug) 12 | 13 | ### [3.11.0] 2025/03/22 14 | 15 | **IMPROVEMENTS:** 16 | 17 | - Introduced new field to `TransactionInfo`: `appTransactionId` 18 | - Introduced new fields to `RenewalInfo`: `appAccountToken`, `appTransactionId`, `currency`, `eligibleWinBackOfferIds`, `offerDiscountType`, `offerPeriod`, `renewalPrice` 19 | 20 | ### [3.10.2] 2024-10-22 21 | 22 | **BUGFIX:** 23 | 24 | - Calling `sendConsumptionInformation()` w/o passing optional parameter `refundPreference` results in fatal error (kudos to @javiermarinros) 25 | 26 | **IMPROVEMENTS:** 27 | 28 | - Insignificant refactoring of `AbstractRequestParamsBag` class 29 | 30 | ### [3.10.1] 2024-10-18 31 | 32 | **BUGFIX:** 33 | - `AbstractRequestParamsBag::isValueMatchingPropValues()` fixed (double negation if value is an array, kudos to @neoighodaro) 34 | - `AbstractRequestQueryParams::getQueryString()` fixed (bool values are now explicitly converted to strings, kudos to @neoighodaro) 35 | 36 | ### [3.10.0] 2024-10-18 37 | 38 | **IMPROVEMENTS:** 39 | 40 | - New field `consumptionRequestReason` added to `AppMetadata` 41 | - New field `refundPreference` (and corresponding constants) added to `ConsumptionRequestBody` (kudos to @sedlak477) 42 | 43 | ### [3.9.0] 2024-10-07 44 | 45 | **IMPROVEMENTS:** 46 | 47 | - In-app purchase history V2 support added (kudos to @anegve) 48 | 49 | ### [3.8.1] 2024-07-08 50 | 51 | **BUGFIX:** 52 | 53 | - Default TTL for payload introduced and set to 5 min. Previous value of 1 hour (which is the maximum) seems to be the cause of failed responses in some cases. 54 | - Makefile introduced just to have a shortcut 'make shell' for running Docker container using PHP image and having project directory mounted 55 | 56 | ### [3.8.0] 2024-04-08 57 | 58 | **IMPROVEMENTS:** 59 | 60 | - Nullable properties now are NOT converted to empty int/bool/float/string in AppMetadata, RenewalInfo, ResponseBodyV2, TransactionInfo, kudos to @dbrkv for pointing this out 61 | - ArrayTypeCastGenerator moved to the separate class 62 | - PHPUnit tests introduced (just the first one for ArrayTypeCastGenerator atm) 63 | - Examples reworked a bit (RenewalInfo/TransactionInfo printing moved to the separate helper function) 64 | 65 | ### [3.7.0] 2024-03-28 66 | 67 | **IMPROVEMENTS:** 68 | 69 | - Now the response content of the HTTP response is available in `HTTPRequestFailed` exception using `getResponseText()` method, kudos to @soxft for pointing this out 70 | 71 | ### [3.6.3] 2024-03-25 72 | 73 | **BUGFIX:** 74 | 75 | - Handle empty response headers in case if HTTP request to the API fails (and it fails regularly, kudos to Apple) 76 | 77 | ### [3.6.2] 2024-01-25 78 | 79 | **BUGFIX:** 80 | 81 | - If the certificate string already has a prefix, there is no need to add it 82 | 83 | ### [3.6.1] 2023-12-19 84 | 85 | **BUGFIX:** 86 | 87 | - Treat "202 Accepted" as successful response (App Store returns it on "Send consumption information" request), kudos to @teanooki for pointing this out 88 | 89 | ### [3.6.0] 2023-12-11 90 | 91 | **IMPROVEMENTS:** 92 | 93 | New fields implemented 94 | - `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) 95 | 96 | ### [3.5.2] 2023-10-10 97 | 98 | **BUGFIX:** 99 | 100 | - Logic issue in PageableResponse, after fixing syntax issue in `3.5.1` 101 | 102 | ### [3.5.1] 2023-10-05 103 | 104 | **BUGFIX:** 105 | 106 | - Syntax issue in PageableResponse for PHP 7.4, kudos to @JamieSTV 107 | 108 | ### [3.5.0] 2023-09-21 109 | 110 | **IMPROVEMENTS:** 111 | 112 | Missing endpoints added: 113 | - Send Consumption Information 114 | - Extend a Subscription Renewal Date 115 | - Extend Subscription Renewal Dates for All Active Subscribers 116 | - Get Status of Subscription Renewal Date Extensions 117 | 118 | ### [3.4.1] 2023-09-17 119 | 120 | **BUGFIX:** 121 | 122 | - `TransactionInfo`: `storefront`, `storefrontId`, and `transactionReason` are now nullable and null by default, in order to be compatible with old notifications 123 | - `RenewalInfo`: `renewalDate` is now null by default, in order to be compatible with old notifications 124 | - `Response\NotificationHistoryResponse`: `paginationToken` presence in response is now optional 125 | 126 | ### [3.4.0] 2023-09-16 127 | 128 | **IMPROVEMENTS:** 129 | 130 | - New `notificationType`/`subtype` in `ResponseBodyV2` 131 | 132 | ### [3.3.2] 2023-09-16 133 | 134 | **BUGFIX:** 135 | 136 | - ASN1SequenceOfInteger: multiple `00` bytes in the beginning of integer numbers handled when parsing HEX signature representation 137 | 138 | ### [3.3.1] 2023-09-07 139 | 140 | **BUGFIX:** 141 | 142 | - `AppMetadata`: `bundleId`, `bundleVersion`, `renewalInfo`, `transactionInfo` and `status` now are `NULL` by default (to prevent `Typed property ... must not be accessed before initialization` error) 143 | 144 | ### [3.3.0] 2023-09-06 145 | 146 | **IMPROVEMENTS:** 147 | 148 | - New field implemented 149 | - `AppMetadata`: `status` 150 | 151 | ### [3.2.0] 2023-09-03 152 | 153 | **IMPROVEMENTS:** 154 | 155 | - New fields implemented 156 | - `RenewalInfo`: `renewalDate` 157 | - `TransactionInfo`: `storefront`, `storefrontId`, `transactionReason` 158 | 159 | ### [3.1.1] 2023-09-03 160 | 161 | **BUGFIX:** 162 | 163 | - `ResponseBodyV2`: `createFromRawNotification()` fix, now it checks incoming notification to be not only a valid JSON, but also to be an array 164 | 165 | ### [3.1.0] 2023-08-26 166 | 167 | **BUGFIX:** 168 | 169 | - `ASN1SequenceOfInteger`: math fixes 170 | - `StatusResponse`: `data` array initialization with `[]` 171 | 172 | **IMPROVEMENTS:** 173 | 174 | - `HTTPRequest`: PUT method added; HTTP method and URL added to `HTTPRequestFailed` exception message 175 | - `JWT`: additional information in exception message 176 | 177 | ### [3.0.1] 2023-08-23 178 | 179 | **BUGFIX:** 180 | 181 | - Math bug fixed in `ASN1SequenceOfInteger`. In rare cases signature was calculated in a wrong way which led to `Wrong signature` exception in `JWT::verifySignature` 182 | 183 | ### [3.0.0] 2023-08-18 184 | 185 | ***BREAKING CHANGES:*** 186 | 187 | - Main classes renamed: 188 | - `APIClient` -> `AppStoreServerAPI` 189 | - `APIClientInterface` -> `AppStoreServerAPIInterface` 190 | - `Notification\ResponseBodyV2` -> `ResponseBodyV2` 191 | - `JWT` -> `Util\JWT` 192 | - `Request\GetTransactionHistory` -> `Request\GetTransactionHistoryRequest` 193 | - `Request\RequestTestNotification` -> `Request\RequestTestNotificationRequest` 194 | - `Request\GetTransactionHistoryQueryParams` -> `RequestQueryParams\GetTransactionHistoryQueryParams` 195 | - Environment consts moved out from all classes to the separate class `Environment` 196 | - `getTransactionHistory()` method signature changed: it no longer expects for QueryParams instance as a second arguments, now it expects array instead 197 | - `AppStoreServerAPI` (previously `APIClient`) constructor signature changed: 198 | - `$environment` argument type changed from int to string 199 | - `$keyId` and `$key` arguments swapped 200 | 201 | **IMPROVEMENTS:** 202 | 203 | - PHP 7.4 support out of the box ;) 204 | - A lot of new endpoints (see [README](https://github.com/readdle/app-store-server-api/blob/master/README.md)) 205 | - Examples for all implemented endpoints (and notification listener) 206 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: shell 2 | 3 | shell: 4 | docker run --rm -it -w /app -v $(shell pwd):/app php:8.3 bash 5 | -------------------------------------------------------------------------------- /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 | ### App Store Server API version compatibility: 1.15 - 2025/02/21 12 | 13 | # Installation 14 | 15 | Nothing special here, just use composer to install the package: 16 | 17 | > composer require readdle/app-store-server-api 18 | 19 | # Usage 20 | 21 | ### App Store Server API 22 | 23 | API initialization: 24 | 25 | ``` 26 | try { 27 | $api = new \Readdle\AppStoreServerAPI\AppStoreServerAPI( 28 | \Readdle\AppStoreServerAPI\Environment::PRODUCTION, 29 | '1a2b3c4d-1234-4321-1111-1a2b3c4d5e6f', 30 | 'com.readdle.MyBundle', 31 | 'ABC1234DEF', 32 | "-----BEGIN PRIVATE KEY-----\n\n-----END PRIVATE KEY-----" 33 | ); 34 | } catch (\Readdle\AppStoreServerAPI\Exception\WrongEnvironmentException $e) { 35 | exit($e->getMessage()); 36 | } 37 | ``` 38 | 39 | Performing API call: 40 | 41 | ``` 42 | try { 43 | $transactionHistory = $api->getTransactionHistory($transactionId, ['sort' => GetTransactionHistoryQueryParams::SORT__DESCENDING]); 44 | $transactions = $transactionHistory->getTransactions(); 45 | } catch (\Readdle\AppStoreServerAPI\Exception\AppStoreServerAPIException $e) { 46 | exit($e->getMessage()); 47 | } 48 | ``` 49 | 50 | ### App Store Server Notifications 51 | 52 | ``` 53 | try { 54 | $responseBodyV2 = \Readdle\AppStoreServerAPI\ResponseBodyV2::createFromRawNotification( 55 | '{"signedPayload":"..."}', 56 | \Readdle\AppStoreServerAPI\Util\Helper::toPEM(file_get_contents('https://www.apple.com/certificateauthority/AppleRootCA-G3.cer')) 57 | ); 58 | } catch (\Readdle\AppStoreServerAPI\Exception\AppStoreServerNotificationException $e) { 59 | exit('Server notification could not be processed: ' . $e->getMessage()); 60 | } 61 | ``` 62 | 63 | # Examples 64 | 65 | 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. 66 | 67 | In order to run examples you have to create `credentials.json` and/or `notifications.json` inside `examples/` directory. 68 | 69 | `credentials.json` structure should be as follows: 70 | 71 | ``` 72 | { 73 | "env": "Production", 74 | "issuerId": "1a2b3c4d-1234-4321-1111-1a2b3c4d5e6f", 75 | "bundleId": "com.readdle.MyBundle", 76 | "keyId": "ABC1234DEF", 77 | "key": "-----BEGIN PRIVATE KEY-----\n\n-----END PRIVATE KEY-----", 78 | "orderId": "ABC1234DEF", 79 | "transactionId": "123456789012345" 80 | } 81 | ``` 82 | 83 | 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. 84 | 85 | For `Order ID lookup` you have to specify `orderId`. This endpoint (and, consequently, the example) is not available in the sandbox environment. 86 | 87 | `notification.json` structure is the same as you receive it in your server-to-server notification endpoint: 88 | 89 | ``` 90 | {"signedPayload":""} 91 | ``` 92 | 93 | # What is covered 94 | 95 | ### In-app purchase history V1 (Deprecated, but left for backwards compatibility) 96 | 97 | #### [Get Transaction History](https://developer.apple.com/documentation/appstoreserverapi/get_transaction_history_v1) 98 | 99 | `AppStoreServerAPI::getTransactionHistory(string $transactionId, array $queryParams)` 100 | 101 | Get a customer’s in-app purchase transaction history for your app. 102 | 103 | ### In-app purchase history V2 104 | 105 | #### [Get Transaction History](https://developer.apple.com/documentation/appstoreserverapi/get_transaction_history) 106 | 107 | `AppStoreServerAPI::getTransactionHistoryV2(string $transactionId, array $queryParams)` 108 | 109 | Get a customer’s in-app purchase transaction history for your app. 110 | 111 | ### Transaction Info 112 | 113 | #### [Get Transaction Info](https://developer.apple.com/documentation/appstoreserverapi/get_transaction_info) 114 | 115 | `AppStoreServerAPI::getTransactionInfo(string $transactionId)` 116 | 117 | Get information about a single transaction for your app. 118 | 119 | ### Subscription status 120 | 121 | #### [Get All Subscription Statuses](https://developer.apple.com/documentation/appstoreserverapi/get_all_subscription_statuses) 122 | 123 | `AppStoreServerAPI::getAllSubscriptionStatuses(string $transactionId, array $queryParams = [])` 124 | 125 | Get the statuses for all of a customer’s auto-renewable subscriptions in your app. 126 | 127 | ### Consumption information 128 | 129 | #### [Send Consumption Information](https://developer.apple.com/documentation/appstoreserverapi/send_consumption_information) 130 | 131 | `AppStoreServerAPI::sendConsumptionInformation(string $transactionId, array $requestBody)` 132 | 133 | Send consumption information about a consumable in-app purchase to the App Store after your server receives a consumption request notification. 134 | 135 | ### Order ID lookup 136 | 137 | #### [Look Up Order ID](https://developer.apple.com/documentation/appstoreserverapi/look_up_order_id) 138 | 139 | `AppStoreServerAPI::lookUpOrderId(string $orderId)` 140 | 141 | Get a customer’s in-app purchases from a receipt using the order ID. 142 | 143 | ### Refund lookup 144 | 145 | #### [Get Refund History](https://developer.apple.com/documentation/appstoreserverapi/get_refund_history) 146 | 147 | `AppStoreServerAPI::getRefundHistory(string $transactionId)` 148 | 149 | Get a list of all of a customer’s refunded in-app purchases for your app. 150 | 151 | ### Subscription-renewal-date extension 152 | 153 | #### [Extend a Subscription Renewal Date](https://developer.apple.com/documentation/appstoreserverapi/extend_a_subscription_renewal_date) 154 | 155 | `AppStoreServerAPI::extendSubscriptionRenewalDate(string $originalTransactionId, array $requestBody)` 156 | 157 | Extends the renewal date of a customer’s active subscription using the original transaction identifier. 158 | 159 | #### [Extend Subscription Renewal Dates for All Active Subscribers](https://developer.apple.com/documentation/appstoreserverapi/extend_subscription_renewal_dates_for_all_active_subscribers) 160 | 161 | `AppStoreServerAPI::massExtendSubscriptionRenewalDate(array $requestBody)` 162 | 163 | Uses a subscription’s product identifier to extend the renewal date for all of its eligible active subscribers. 164 | 165 | #### [Get Status of Subscription Renewal Date Extensions](https://developer.apple.com/documentation/appstoreserverapi/get_status_of_subscription_renewal_date_extensions) 166 | 167 | `AppStoreServerAPI::getStatusOfSubscriptionRenewalDateExtensionsRequest(string $productId, string $requestIdentifier)` 168 | 169 | Checks whether a renewal date extension request completed, and provides the final count of successful or failed extensions. 170 | 171 | ### App Store Server Notifications history 172 | 173 | #### [Get Notification History](https://developer.apple.com/documentation/appstoreserverapi/get_notification_history) 174 | 175 | `AppStoreServerAPI::getNotificationHistory(array $requestBody)` 176 | 177 | Get a list of notifications that the App Store server attempted to send to your server. 178 | 179 | ### App Store Server Notifications testing 180 | 181 | #### [Request a Test Notification](https://developer.apple.com/documentation/appstoreserverapi/request_a_test_notification) 182 | 183 | `AppStoreServerAPI::requestTestNotification()` 184 | 185 | Ask App Store Server Notifications to send a test notification to your server. 186 | 187 | #### [Get Test Notification Status](https://developer.apple.com/documentation/appstoreserverapi/get_test_notification_status) 188 | 189 | `AppStoreServerAPI::getTestNotificationStatus(string $testNotificationToken)` 190 | 191 | Check the status of the test App Store server notification sent to your server. 192 | -------------------------------------------------------------------------------- /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.12.0", 27 | "php": ">=7.4", 28 | "autoload": { 29 | "psr-4": { 30 | "Readdle\\AppStoreServerAPI\\": "src/" 31 | } 32 | }, 33 | "autoload-dev": { 34 | "psr-4": { 35 | "Readdle\\AppStoreServerAPI\\Tests": "tests/" 36 | } 37 | }, 38 | "require": { 39 | "ext-json": "*", 40 | "ext-openssl": "*" 41 | }, 42 | "require-dev": { 43 | "friendsofphp/php-cs-fixer": "^3.23", 44 | "phpstan/phpstan": "^1.10", 45 | "phpunit/phpunit": "^10.5" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /examples/client.php: -------------------------------------------------------------------------------- 1 | getMessage()); 47 | } 48 | 49 | return [ 50 | 'api' => $api, 51 | 'credentials' => $credentials, 52 | ]; 53 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /examples/getAllSubscriptionStatuses.php: -------------------------------------------------------------------------------- 1 | $api, 'credentials' => $credentials] = require 'client.php'; 5 | require_once 'helper.php'; 6 | 7 | use Readdle\AppStoreServerAPI\Exception\AppStoreServerAPIException; 8 | 9 | try { 10 | $allSubscriptionStatuses = $api->getAllSubscriptionStatuses($credentials['transactionId']); 11 | } catch (AppStoreServerAPIException $e) { 12 | exit($e->getMessage()); 13 | } 14 | 15 | echo "App Apple ID: {$allSubscriptionStatuses->getAppAppleId()}\n"; 16 | echo "Bundle ID: {$allSubscriptionStatuses->getBundleId()}\n"; 17 | echo "Environment: {$allSubscriptionStatuses->getEnvironment()}\n"; 18 | echo "\n\n"; 19 | 20 | foreach ($allSubscriptionStatuses->getData() as $subscriptionGroupIdentifierItem) { 21 | echo "Subscription Group Identifier: {$subscriptionGroupIdentifierItem->getSubscriptionGroupIdentifier()}\n"; 22 | echo "Last Transactions\n=================\n\n"; 23 | 24 | foreach ($subscriptionGroupIdentifierItem->getLastTransactions() as $lastTransactionsItem) { 25 | echo "Original Transaction ID: {$lastTransactionsItem->getOriginalTransactionId()}\n"; 26 | echo "Status: {$lastTransactionsItem->getStatus()}\n"; 27 | 28 | $transactionInfo = $lastTransactionsItem->getTransactionInfo(); 29 | echo "\nTransaction Info\n----------------\n"; 30 | printJsonSerializableEntity($transactionInfo); 31 | echo "\nAs JSON: " . json_encode($transactionInfo) . "\n"; 32 | 33 | $renewalInfo = $lastTransactionsItem->getRenewalInfo(); 34 | echo "\nRenewal Info\n------------\n"; 35 | printJsonSerializableEntity($renewalInfo); 36 | echo "\nAs JSON: " . json_encode($renewalInfo) . "\n"; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /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/getRefundHistory.php: -------------------------------------------------------------------------------- 1 | $api, 'credentials' => $credentials] = require 'client.php'; 5 | require_once 'helper.php'; 6 | 7 | use Readdle\AppStoreServerAPI\Exception\AppStoreServerAPIException; 8 | 9 | try { 10 | $refundHistory = $api->getRefundHistory($credentials['transactionId']); 11 | $transactions = $refundHistory->getTransactions(); 12 | } catch (AppStoreServerAPIException $e) { 13 | exit($e->getMessage()); 14 | } 15 | 16 | echo "Refund history\n\n"; 17 | 18 | foreach ($transactions as $i => $transactionInfo) { 19 | $header = "Transaction Info #$i"; 20 | $delimiter = str_repeat('-', strlen($header)); 21 | echo "$header\n$delimiter\n"; 22 | printJsonSerializableEntity($transactionInfo); 23 | echo "\nAs JSON: " . json_encode($transactionInfo) . "\n\n"; 24 | } 25 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /examples/getTransactionHistory.php: -------------------------------------------------------------------------------- 1 | $api, 'credentials' => $credentials] = require 'client.php'; 5 | require_once 'helper.php'; 6 | 7 | use Readdle\AppStoreServerAPI\Exception\AppStoreServerAPIException; 8 | 9 | try { 10 | $transactionHistory = $api->getTransactionHistory($credentials['transactionId'], ['sort' => 'ASCENDING']); 11 | $transactions = $transactionHistory->getTransactions(); 12 | } catch (AppStoreServerAPIException $e) { 13 | exit($e->getMessage()); 14 | } 15 | 16 | echo "App Apple ID: {$transactionHistory->getAppAppleId()}\n"; 17 | echo "Bundle ID: {$transactionHistory->getBundleId()}\n"; 18 | echo "Environment: {$transactionHistory->getEnvironment()}\n"; 19 | echo "\n"; 20 | 21 | foreach ($transactions as $i => $transactionInfo) { 22 | $header = "Transaction Info #$i"; 23 | $delimiter = str_repeat('-', strlen($header)); 24 | echo "$header\n$delimiter\n"; 25 | printJsonSerializableEntity($transactionInfo); 26 | echo "\nAs JSON: " . json_encode($transactionInfo) . "\n\n"; 27 | } 28 | -------------------------------------------------------------------------------- /examples/getTransactionHistoryV2.php: -------------------------------------------------------------------------------- 1 | $api, 'credentials' => $credentials] = require 'client.php'; 5 | require_once 'helper.php'; 6 | 7 | use Readdle\AppStoreServerAPI\Exception\AppStoreServerAPIException; 8 | 9 | try { 10 | $transactionHistory = $api->getTransactionHistoryV2($credentials['transactionId'], ['sort' => 'ASCENDING']); 11 | $transactions = $transactionHistory->getTransactions(); 12 | } catch (AppStoreServerAPIException $e) { 13 | exit($e->getMessage()); 14 | } 15 | 16 | echo "App Apple ID: {$transactionHistory->getAppAppleId()}\n"; 17 | echo "Bundle ID: {$transactionHistory->getBundleId()}\n"; 18 | echo "Environment: {$transactionHistory->getEnvironment()}\n"; 19 | echo "\n"; 20 | 21 | foreach ($transactions as $i => $transactionInfo) { 22 | $header = "Transaction Info #$i"; 23 | $delimiter = str_repeat('-', strlen($header)); 24 | echo "$header\n$delimiter\n"; 25 | printJsonSerializableEntity($transactionInfo); 26 | echo "\nAs JSON: " . json_encode($transactionInfo) . "\n\n"; 27 | } 28 | -------------------------------------------------------------------------------- /examples/getTransactionInfo.php: -------------------------------------------------------------------------------- 1 | $api, 'credentials' => $credentials] = require 'client.php'; 5 | require_once 'helper.php'; 6 | 7 | use Readdle\AppStoreServerAPI\Exception\AppStoreServerAPIException; 8 | 9 | try { 10 | $transactionInfoResponse = $api->getTransactionInfo($credentials['transactionId']); 11 | } catch (AppStoreServerAPIException $e) { 12 | exit($e->getMessage()); 13 | } 14 | 15 | $transactionInfo = $transactionInfoResponse->getTransactionInfo(); 16 | 17 | printJsonSerializableEntity($transactionInfo); 18 | echo "\nAs JSON: " . json_encode($transactionInfo) . "\n\n"; 19 | -------------------------------------------------------------------------------- /examples/helper.php: -------------------------------------------------------------------------------- 1 | jsonSerialize() as $property => $value) { 7 | $displayName = preg_replace_callback('/(?!^)[A-Z]/', fn (array $match) => ' ' . $match[0], ucfirst($property)); 8 | 9 | switch (true) { 10 | case $value === '': 11 | $value = '""'; 12 | 13 | break; 14 | 15 | case $value === null: 16 | $value = ''; 17 | 18 | break; 19 | 20 | case is_array($value): 21 | $value = json_encode($value); 22 | 23 | break; 24 | } 25 | 26 | echo "$displayName: $value\n"; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /examples/lookUpOrderId.php: -------------------------------------------------------------------------------- 1 | $api, 'credentials' => $credentials] = require 'client.php'; 5 | require_once 'helper.php'; 6 | 7 | if (empty($credentials['orderId'])) { 8 | exit('You have to specify orderId in order to look it up'); 9 | } 10 | 11 | use Readdle\AppStoreServerAPI\Exception\AppStoreServerAPIException; 12 | 13 | try { 14 | $orderLookup = $api->lookUpOrderId($credentials['orderId']); 15 | } catch (AppStoreServerAPIException $e) { 16 | exit($e->getMessage()); 17 | } 18 | 19 | echo "Order Lookup\n"; 20 | echo "Status: {$orderLookup->getStatus()}\n\n"; 21 | 22 | foreach ($orderLookup->getTransactions() as $i => $transactionInfo) { 23 | echo "Transaction Info #$i\n"; 24 | $header = "Transaction Info #$i"; 25 | $delimiter = str_repeat('-', strlen($header)); 26 | echo "$header\n$delimiter\n"; 27 | printJsonSerializableEntity($transactionInfo); 28 | echo "\nAs JSON: " . json_encode($transactionInfo) . "\n\n"; 29 | } 30 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /examples/serverNotificationV2.php: -------------------------------------------------------------------------------- 1 | getMessage()); 25 | } 26 | 27 | echo "Notification type: {$responseBodyV2->getNotificationType()}\n"; 28 | echo "Notification subtype: {$responseBodyV2->getSubtype()}\n"; 29 | echo "Notification UUID: {$responseBodyV2->getNotificationUUID()}\n"; 30 | echo "Version: {$responseBodyV2->getVersion()}\n"; 31 | echo "Signed date: {$responseBodyV2->getSignedDate()}\n"; 32 | 33 | echo "\nAs JSON: " . json_encode($responseBodyV2) . "\n"; 34 | 35 | $appMetadata = $responseBodyV2->getAppMetadata(); 36 | echo "\nApp Metadata\n"; 37 | echo "App Apple ID: {$appMetadata->getAppAppleId()}\n"; 38 | echo "Bundle ID: {$appMetadata->getBundleId()}\n"; 39 | echo "Bundle Version: {$appMetadata->getBundleVersion()}\n"; 40 | echo "Environment: {$appMetadata->getEnvironment()}\n"; 41 | 42 | echo "\nAs JSON: " . json_encode($appMetadata) . "\n"; 43 | 44 | $transactionInfo = $appMetadata->getTransactionInfo(); 45 | 46 | if ($transactionInfo) { 47 | echo "\nTransaction Info\n----------------\n"; 48 | printJsonSerializableEntity($transactionInfo); 49 | echo "\nAs JSON: " . json_encode($transactionInfo) . "\n"; 50 | } 51 | 52 | $renewalInfo = $appMetadata->getRenewalInfo(); 53 | 54 | if ($renewalInfo) { 55 | echo "\nRenewal Info\n------------\n"; 56 | printJsonSerializableEntity($renewalInfo); 57 | echo "\nAs JSON: " . json_encode($renewalInfo) . "\n"; 58 | } 59 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | paths: 3 | - src 4 | level: 6 5 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | src 6 | 7 | 8 | 9 | 10 | 11 | tests/Unit 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/AppMetadata.php: -------------------------------------------------------------------------------- 1 | $rawData 67 | */ 68 | public static function createFromRawData(array $rawData): self 69 | { 70 | $appMetadata = new self(); 71 | $typeCaster = (new ArrayTypeCaseGenerator())($rawData, [ 72 | 'int' => ['?status'], 73 | 'string' => ['?appAppleId', '?bundleId', '?bundleVersion', '?consumptionRequestReason', 'environment'], 74 | ]); 75 | 76 | foreach ($typeCaster as $prop => $value) { 77 | $appMetadata->$prop = $value; 78 | } 79 | 80 | if (array_key_exists('renewalInfo', $rawData) && $rawData['renewalInfo'] instanceof RenewalInfo) { 81 | $appMetadata->renewalInfo = $rawData['renewalInfo']; 82 | } 83 | 84 | if (array_key_exists('transactionInfo', $rawData) && $rawData['transactionInfo'] instanceof TransactionInfo) { 85 | $appMetadata->transactionInfo = $rawData['transactionInfo']; 86 | } 87 | 88 | return $appMetadata; 89 | } 90 | 91 | /** 92 | * Returns the unique identifier of the app that the notification applies to. 93 | * This property is available for apps that are downloaded from the App Store; it isn't present in the sandbox 94 | * environment. 95 | */ 96 | public function getAppAppleId(): ?string 97 | { 98 | return $this->appAppleId; 99 | } 100 | 101 | /** 102 | * Returns the bundle identifier of the app. 103 | */ 104 | public function getBundleId(): ?string 105 | { 106 | return $this->bundleId; 107 | } 108 | 109 | /** 110 | * Returns the version of the build that identifies an iteration of the bundle. 111 | */ 112 | public function getBundleVersion(): ?string 113 | { 114 | return $this->bundleVersion; 115 | } 116 | 117 | /** 118 | * Returns the reason the customer requested the refund. 119 | */ 120 | public function getConsumptionRequestReason(): ?string 121 | { 122 | return $this->consumptionRequestReason; 123 | } 124 | 125 | /** 126 | * Returns the server environment that the notification applies to, either sandbox or production. 127 | * 128 | * @return Environment::PRODUCTION|Environment::SANDBOX 129 | */ 130 | public function getEnvironment(): string 131 | { 132 | return $this->environment; 133 | } 134 | 135 | /** 136 | * Returns subscription renewal information (if any). 137 | */ 138 | public function getRenewalInfo(): ?RenewalInfo 139 | { 140 | return $this->renewalInfo; 141 | } 142 | 143 | /** 144 | * Returns transaction information (if any). 145 | */ 146 | public function getTransactionInfo(): ?TransactionInfo 147 | { 148 | return $this->transactionInfo; 149 | } 150 | 151 | /** 152 | * Returns the status of an auto-renewable subscription as of the signedDate in the ResponseBodyV2. 153 | */ 154 | public function getStatus(): ?int 155 | { 156 | return $this->status; 157 | } 158 | 159 | /** 160 | * @return array 161 | */ 162 | public function jsonSerialize(): array 163 | { 164 | return get_object_vars($this); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/AppStoreServerAPI.php: -------------------------------------------------------------------------------- 1 | environment = $environment; 73 | $this->payload = new Payload($issuerId, $bundleId); 74 | $this->key = new Key($keyId, $key); 75 | } 76 | 77 | public function getTransactionHistory(string $transactionId, array $queryParams = []): HistoryResponse 78 | { 79 | /** 80 | * @var HistoryResponse $response 81 | */ 82 | $response = $this->performRequest( 83 | GetTransactionHistoryRequest::class, 84 | HistoryResponse::class, 85 | ['transactionId' => $transactionId], 86 | new GetTransactionHistoryQueryParams($queryParams) 87 | ); 88 | return $response; 89 | } 90 | 91 | public function getTransactionHistoryV2(string $transactionId, array $queryParams = []): HistoryResponse 92 | { 93 | /** 94 | * @var HistoryResponse $response 95 | */ 96 | $response = $this->performRequest( 97 | GetTransactionHistoryRequestV2::class, 98 | HistoryResponse::class, 99 | ['transactionId' => $transactionId], 100 | new GetTransactionHistoryQueryParams($queryParams) 101 | ); 102 | return $response; 103 | } 104 | 105 | public function getTransactionInfo(string $transactionId): TransactionInfoResponse 106 | { 107 | /** 108 | * @var TransactionInfoResponse $response 109 | */ 110 | $response = $this->performRequest( 111 | GetTransactionInfoRequest::class, 112 | TransactionInfoResponse::class, 113 | ['transactionId' => $transactionId], 114 | ); 115 | return $response; 116 | } 117 | 118 | public function getAllSubscriptionStatuses(string $transactionId, array $queryParams = []): StatusResponse 119 | { 120 | /** 121 | * @var StatusResponse $response 122 | */ 123 | $response = $this->performRequest( 124 | GetAllSubscriptionStatusesRequest::class, 125 | StatusResponse::class, 126 | ['transactionId' => $transactionId], 127 | new GetAllSubscriptionStatusesQueryParams($queryParams) 128 | ); 129 | return $response; 130 | } 131 | 132 | public function sendConsumptionInformation(string $transactionId, array $requestBody): void 133 | { 134 | $this->performRequest( 135 | SendConsumptionInformationRequest::class, 136 | null, 137 | ['transactionId' => $transactionId], 138 | null, 139 | new ConsumptionRequestBody($requestBody) 140 | ); 141 | } 142 | 143 | public function lookUpOrderId(string $orderId): OrderLookupResponse 144 | { 145 | /** 146 | * @var OrderLookupResponse $response 147 | */ 148 | $response = $this->performRequest( 149 | LookUpOrderIdRequest::class, 150 | OrderLookupResponse::class, 151 | ['orderId' => $orderId], 152 | ); 153 | return $response; 154 | } 155 | 156 | public function getRefundHistory(string $transactionId): RefundHistoryResponse 157 | { 158 | /** 159 | * @var RefundHistoryResponse $response 160 | */ 161 | $response = $this->performRequest( 162 | GetRefundHistoryRequest::class, 163 | RefundHistoryResponse::class, 164 | ['transactionId' => $transactionId], 165 | new GetRefundHistoryQueryParams() 166 | ); 167 | return $response; 168 | } 169 | 170 | /** 171 | * @inheritdoc 172 | * @throws Exception 173 | */ 174 | public function extendSubscriptionRenewalDate( 175 | string $originalTransactionId, 176 | array $requestBody 177 | ): ExtendRenewalDateResponse { 178 | /** 179 | * @var ExtendRenewalDateResponse $response 180 | */ 181 | $response = $this->performRequest( 182 | ExtendSubscriptionRenewalDateRequest::class, 183 | ExtendRenewalDateResponse::class, 184 | ['originalTransactionId' => $originalTransactionId], 185 | null, 186 | new ExtendRenewalDateRequestBody($requestBody) 187 | ); 188 | return $response; 189 | 190 | } 191 | 192 | /** 193 | * @inheritdoc 194 | * @throws Exception 195 | */ 196 | public function massExtendSubscriptionRenewalDate(array $requestBody): MassExtendRenewalDateResponse 197 | { 198 | /** 199 | * @var MassExtendRenewalDateResponse $response 200 | */ 201 | $response = $this->performRequest( 202 | MassExtendSubscriptionRenewalDateRequest::class, 203 | MassExtendRenewalDateResponse::class, 204 | [], 205 | null, 206 | new MassExtendRenewalDateRequestBody($requestBody) 207 | ); 208 | return $response; 209 | 210 | } 211 | 212 | public function getStatusOfSubscriptionRenewalDateExtensionsRequest( 213 | string $productId, 214 | string $requestIdentifier 215 | ): MassExtendRenewalDateStatusResponse { 216 | /** 217 | * @var MassExtendRenewalDateStatusResponse $response 218 | */ 219 | $response = $this->performRequest( 220 | GetStatusOfSubscriptionRenewalDateExtensionsRequest::class, 221 | MassExtendRenewalDateStatusResponse::class, 222 | [ 223 | 'productId' => $productId, 224 | 'requestIdentifier' => $requestIdentifier, 225 | ] 226 | ); 227 | return $response; 228 | } 229 | 230 | 231 | public function getNotificationHistory(array $requestBody): NotificationHistoryResponse 232 | { 233 | /** 234 | * @var NotificationHistoryResponse $response 235 | */ 236 | $response = $this->performRequest( 237 | GetNotificationHistoryRequest::class, 238 | NotificationHistoryResponse::class, 239 | [], 240 | new GetNotificationHistoryQueryParams(), 241 | new NotificationHistoryRequestBody($requestBody) 242 | ); 243 | return $response; 244 | } 245 | 246 | public function requestTestNotification(): SendTestNotificationResponse 247 | { 248 | /** 249 | * @var SendTestNotificationResponse $response 250 | */ 251 | $response = $this->performRequest(RequestTestNotificationRequest::class, SendTestNotificationResponse::class); 252 | return $response; 253 | } 254 | 255 | public function getTestNotificationStatus(string $testNotificationToken): CheckTestNotificationResponse 256 | { 257 | /** 258 | * @var CheckTestNotificationResponse $response 259 | */ 260 | $response = $this->performRequest( 261 | GetTestNotificationStatusRequest::class, 262 | CheckTestNotificationResponse::class, 263 | ['testNotificationToken' => $testNotificationToken], 264 | ); 265 | return $response; 266 | 267 | } 268 | 269 | private function createRequest( 270 | string $requestClass, 271 | ?AbstractRequestQueryParams $queryParams, 272 | ?AbstractRequestBody $body 273 | ): AbstractRequest { 274 | /** @var AbstractRequest $request */ 275 | $request = new $requestClass($this->key, $this->payload, $queryParams, $body); 276 | $request->setURLVars(['baseUrl' => $this->getBaseURL()]); 277 | return $request; 278 | } 279 | 280 | private function getBaseURL(): string 281 | { 282 | return $this->environment === Environment::PRODUCTION ? self::PRODUCTION_BASE_URL : self::SANDBOX_BASE_URL; 283 | } 284 | 285 | /** 286 | * @param array $requestUrlVars 287 | * 288 | * @throws HTTPRequestAborted 289 | * @throws HTTPRequestFailed 290 | * @throws InvalidImplementationException 291 | * @throws MalformedResponseException 292 | * @throws UnimplementedContentTypeException 293 | */ 294 | private function performRequest( 295 | string $requestClass, 296 | ?string $responseClass, 297 | array $requestUrlVars = [], 298 | ?AbstractRequestQueryParams $requestQueryParams = null, 299 | ?AbstractRequestBody $requestBody = null 300 | ): ?AbstractResponse { 301 | if ( 302 | !is_subclass_of($requestClass, AbstractRequest::class) 303 | || (!empty($responseClass) && !is_subclass_of($responseClass, AbstractResponse::class)) 304 | ) { 305 | throw new InvalidImplementationException($requestClass, $responseClass); 306 | } 307 | 308 | $request = $this->createRequest($requestClass, $requestQueryParams, $requestBody); 309 | 310 | if (!empty($requestUrlVars)) { 311 | $request->setURLVars($requestUrlVars); 312 | } 313 | 314 | $responseText = HTTPRequest::performRequest($request); 315 | 316 | if (empty($responseClass)) { 317 | return null; 318 | } 319 | 320 | return call_user_func([$responseClass, 'createFromString'], $responseText, $request); 321 | } 322 | } 323 | -------------------------------------------------------------------------------- /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/Environment.php: -------------------------------------------------------------------------------- 1 | responseText = $responseText; 13 | 14 | if ($statusCode === 0) { 15 | parent::__construct("HTTP request [$method $url] failed: $message"); 16 | } else { 17 | parent::__construct("HTTP request [$method $url] failed with status code $statusCode. Response text is: $responseText", $statusCode); 18 | } 19 | } 20 | 21 | public function getResponseText(): string 22 | { 23 | return $this->responseText; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Exception/InvalidImplementationException.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, '', $statusCode, $response); 76 | } 77 | 78 | return $response; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Key.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/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/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/Payload.php: -------------------------------------------------------------------------------- 1 | issuerId = $issuerId; 36 | $this->bundleId = $bundleId; 37 | 38 | $this->ttl = $ttl === 0 ? self::DEFAULT_TTL : min($ttl, self::MAX_TTL); 39 | } 40 | 41 | /** 42 | * @return array 43 | */ 44 | public function toArray(): array 45 | { 46 | $time = time(); 47 | 48 | return [ 49 | 'iss' => $this->issuerId, 50 | 'iat' => $time, 51 | 'exp' => $time + $this->ttl, 52 | 'aud' => $this->audience, 53 | 'bid' => $this->bundleId, 54 | ]; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/RenewalInfo.php: -------------------------------------------------------------------------------- 1 | $rawRenewalInfo 218 | */ 219 | public static function createFromRawRenewalInfo(array $rawRenewalInfo): self 220 | { 221 | $renewalInfo = new self(); 222 | $typeCaster = (new ArrayTypeCaseGenerator())($rawRenewalInfo, [ 223 | 'int' => [ 224 | 'autoRenewStatus', '?expirationIntent', '?gracePeriodExpiresDate', '?offerType', 225 | '?priceIncreaseStatus', 'recentSubscriptionStartDate', '?renewalDate', '?renewalPrice', 'signedDate', 226 | ], 227 | 'bool' => [ 228 | '?isInBillingRetryPeriod', 229 | ], 230 | 'string' => [ 231 | '?appAccountToken', '?appTransactionId', 'autoRenewProductId', '?currency', 'environment', 232 | '?offerDiscountType', '?offerIdentifier', '?offerPeriod', 'originalTransactionId', 'productId', 233 | ], 234 | 'array' => [ 235 | '?eligibleWinBackOfferIds' 236 | ], 237 | ]); 238 | 239 | foreach ($typeCaster as $prop => $value) { 240 | $renewalInfo->$prop = $value; 241 | } 242 | 243 | return $renewalInfo; 244 | } 245 | 246 | /** 247 | * Returns the UUID that an app optionally generates to map a customer’s In-App Purchase with its resulting App Store 248 | * transaction. 249 | */ 250 | public function getAppAccountToken(): ?string 251 | { 252 | return $this->appAccountToken; 253 | } 254 | 255 | /** 256 | * Returns the unique identifier of the app download transaction. 257 | */ 258 | public function getAppTransactionId(): ?string 259 | { 260 | return $this->appTransactionId; 261 | } 262 | 263 | /** 264 | * Returns the product identifier of the product that renews at the next billing period. 265 | */ 266 | public function getAutoRenewProductId(): string 267 | { 268 | return $this->autoRenewProductId; 269 | } 270 | 271 | /** 272 | * Returns the renewal status for an auto-renewable subscription. 273 | * 274 | * @return self::AUTO_RENEW_STATUS__* 275 | */ 276 | public function getAutoRenewStatus(): int 277 | { 278 | /** @phpstan-ignore-next-line */ 279 | return $this->autoRenewStatus; 280 | } 281 | 282 | /** 283 | * Returns the three-letter ISO 4217 currency code for the price of the product. 284 | */ 285 | public function getCurrency(): ?string 286 | { 287 | return $this->currency; 288 | } 289 | 290 | /** 291 | * Returns an array of win-back offer identifiers that a customer is eligible to redeem, 292 | * which sorts the identifiers with the best offers first. 293 | * 294 | * @return string[] 295 | */ 296 | public function getEligibleWinBackOfferIds(): ?array 297 | { 298 | return $this->eligibleWinBackOfferIds; 299 | } 300 | 301 | /** 302 | * Returns the server environment, either sandbox or production. 303 | * 304 | * @return Environment::PRODUCTION|Environment::SANDBOX 305 | */ 306 | public function getEnvironment(): string 307 | { 308 | return $this->environment; 309 | } 310 | 311 | /** 312 | * Returns the reason a subscription expired (if any). 313 | * 314 | * @return null|self::EXPIRATION_INTENT__* 315 | */ 316 | public function getExpirationIntent(): ?int 317 | { 318 | /** @phpstan-ignore-next-line */ 319 | return $this->expirationIntent; 320 | } 321 | 322 | /** 323 | * Returns the time when the billing grace period for subscription renewals expires (if any). 324 | */ 325 | public function getGracePeriodExpiresDate(): ?int 326 | { 327 | return $this->gracePeriodExpiresDate; 328 | } 329 | 330 | /** 331 | * Returns the Boolean value that indicates whether the App Store is attempting to automatically renew an expired 332 | * subscription. 333 | */ 334 | public function getIsInBillingRetryPeriod(): ?bool 335 | { 336 | return $this->isInBillingRetryPeriod; 337 | } 338 | 339 | /** 340 | * Returns the payment mode for subscription offers on an auto-renewable subscription. 341 | */ 342 | public function getOfferDiscountType(): ?string 343 | { 344 | return $this->offerDiscountType; 345 | } 346 | 347 | /** 348 | * Returns the offer code or the promotional offer identifier (if any). 349 | */ 350 | public function getOfferIdentifier(): ?string 351 | { 352 | return $this->offerIdentifier; 353 | } 354 | 355 | /** 356 | * Returns the duration of the offer. 357 | * 358 | */ 359 | public function getOfferPeriod(): ?string 360 | { 361 | return $this->offerPeriod; 362 | } 363 | 364 | /** 365 | * Returns the type of subscription offer (if any). 366 | * 367 | * @return self::OFFER_TYPE__* 368 | */ 369 | public function getOfferType(): ?int 370 | { 371 | /** @phpstan-ignore-next-line */ 372 | return $this->offerType; 373 | } 374 | 375 | /** 376 | * Returns the original transaction identifier of a purchase. 377 | */ 378 | public function getOriginalTransactionId(): string 379 | { 380 | return $this->originalTransactionId; 381 | } 382 | 383 | /** 384 | * Returns the status that indicates whether the auto-renewable subscription is subject to a price increase (if any) 385 | * 386 | * @return self::PRICE_INCREASE_STATUS__* 387 | */ 388 | public function getPriceIncreaseStatus(): ?int 389 | { 390 | /** @phpstan-ignore-next-line */ 391 | return $this->priceIncreaseStatus; 392 | } 393 | 394 | /** 395 | * Returns the product identifier of the in-app purchase. 396 | */ 397 | public function getProductId(): string 398 | { 399 | return $this->productId; 400 | } 401 | 402 | /** 403 | * Returns the earliest start date of an auto-renewable subscription in a series of subscription purchases 404 | * that ignores all lapses of paid service that are 60 days or less. 405 | */ 406 | public function getRecentSubscriptionStartDate(): int 407 | { 408 | return $this->recentSubscriptionStartDate; 409 | } 410 | 411 | /** 412 | * Returns the UNIX time, in milliseconds, that the most recent auto-renewable subscription purchase expires. 413 | */ 414 | public function getRenewalDate(): ?int 415 | { 416 | return $this->renewalDate; 417 | } 418 | 419 | /** 420 | * Returns the renewal price, in milli-units, of the auto-renewable subscription that renews at the next billing 421 | * period. 422 | */ 423 | public function getRenewalPrice(): ?int 424 | { 425 | return $this->renewalPrice; 426 | } 427 | 428 | /** 429 | * Returns the UNIX time, in milliseconds, that the App Store signed the JSON Web Signature data. 430 | */ 431 | public function getSignedDate(): int 432 | { 433 | return $this->signedDate; 434 | } 435 | 436 | /** 437 | * @return array 438 | */ 439 | public function jsonSerialize(): array 440 | { 441 | return get_object_vars($this); 442 | } 443 | } 444 | -------------------------------------------------------------------------------- /src/Request/AbstractRequest.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 | -------------------------------------------------------------------------------- /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 | $protectedProps = $this->getProtectedProps(); 45 | $protectedPropsCombined = array_combine(array_map(fn ($p) => $p->getName(), $protectedProps), $protectedProps); 46 | 47 | $requiredFields = $this->requiredFields; 48 | 49 | if (in_array('*', $requiredFields)) { 50 | $requiredFields = array_keys($protectedPropsCombined); 51 | } 52 | 53 | $diff = array_diff($requiredFields, array_keys($params)); 54 | 55 | if ($diff) { 56 | throw new Error(vsprintf( 57 | '[%s] Required fields are missing: ["' . join('","', $diff) . '"]', 58 | [get_class($this)] 59 | )); 60 | } 61 | 62 | /** @var array $propValues */ 63 | $propValues = []; 64 | 65 | foreach ($this->getPropConsts() as $constName => $constValue) { 66 | $camelPropName = lcfirst(join(array_map( 67 | fn ($part) => ucfirst(strtolower($part)), 68 | explode('_', explode('__', $constName)[0]) 69 | ))); 70 | $propValues[$camelPropName][] = $constValue; 71 | } 72 | 73 | foreach ($params as $name => $value) { 74 | if ($name === 'revision') { 75 | throw new Error(vsprintf('[%s] Revision could not be set as a parameter', [get_class($this)])); 76 | } 77 | 78 | if (!array_key_exists($name, $protectedPropsCombined)) { 79 | throw new Error(vsprintf('[%s] Unrecognized parameter "%s"', [get_class($this), $name])); 80 | } 81 | 82 | if (!$this->isValueMatchingPropType($value, $protectedPropsCombined[$name])) { 83 | throw new TypeError(vsprintf( 84 | '[%s] Parameter "%s" is of wrong type "%s" ("%s" is expected)', 85 | [get_class($this), $name, gettype($value), $protectedPropsCombined[$name]->getType()] 86 | )); 87 | } 88 | 89 | if (isset($propValues[$name]) && !$this->isValueMatchingPropValues($value, $propValues[$name])) { 90 | throw new UnexpectedValueException(vsprintf( 91 | '[%s] Parameter "%s" has wrong value %s', 92 | [get_class($this), $name, var_export($value, true)] 93 | )); 94 | } 95 | 96 | $this->$name = $value; 97 | } 98 | } 99 | 100 | /** 101 | * @param mixed $value 102 | */ 103 | protected function isValueMatchingPropType($value, ReflectionProperty $prop): bool 104 | { 105 | /** @var ReflectionNamedType $propType */ 106 | $propType = $prop->getType(); 107 | $propTypeName = $propType->getName(); 108 | 109 | switch ($propTypeName) { 110 | case 'int': 111 | $propTypeName = 'integer'; 112 | break; 113 | 114 | case 'bool': 115 | $propTypeName = 'boolean'; 116 | break; 117 | } 118 | 119 | if ($propTypeName === 'array') { 120 | // we don't know what type of value it should be 121 | // probably, the following check for value will do the work 122 | return true; 123 | } 124 | 125 | if (!is_scalar($value)) { 126 | // if the prop's type is not 'array' the value type should match it and, thus, it can't be not scalar 127 | return false; 128 | } 129 | 130 | return $propTypeName === gettype($value); 131 | } 132 | 133 | /** 134 | * @param mixed $value 135 | * @param array $propValues 136 | */ 137 | protected function isValueMatchingPropValues($value, array $propValues): bool 138 | { 139 | if (is_scalar($value)) { 140 | return in_array($value, $propValues); 141 | } 142 | 143 | if (is_array($value)) { 144 | return !array_filter($value, fn ($v) => !in_array($v, $propValues)); 145 | } 146 | 147 | return false; 148 | } 149 | 150 | /** 151 | * @return array 152 | */ 153 | protected function collectProps(): array 154 | { 155 | $props = []; 156 | 157 | foreach ($this->getProtectedProps() as $prop) { 158 | $propName = $prop->getName(); 159 | $value = $this->$propName; 160 | $defaultValue = $prop->getDeclaringClass()->getDefaultProperties()[$propName] ?? null; 161 | 162 | if (!isset($value) || $value === $defaultValue) { 163 | continue; 164 | } 165 | 166 | if (is_array($value)) { 167 | $props[$propName] = array_merge($props[$propName] ?? [], $value); 168 | } else { 169 | $props[$propName] = $value; 170 | } 171 | } 172 | 173 | return $props; 174 | } 175 | 176 | /** 177 | * @return ReflectionProperty[] 178 | */ 179 | protected function getProtectedProps(): array 180 | { 181 | return array_filter( 182 | (new ReflectionClass($this))->getProperties(ReflectionProperty::IS_PROTECTED), 183 | fn ($property) => $property->getName() !== 'requiredFields' 184 | ); 185 | } 186 | 187 | /** 188 | * @return string[] 189 | */ 190 | protected function getPropConsts(): array 191 | { 192 | return array_filter( 193 | (new ReflectionClass($this))->getConstants(), 194 | fn ($const) => strpos($const, '__') !== false, 195 | ARRAY_FILTER_USE_KEY 196 | ); 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /src/Request/ExtendSubscriptionRenewalDateRequest.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/RequestBody/ConsumptionRequestBody.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/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/RequestBody/NotificationHistoryRequestBody.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 | } elseif (is_bool($value)) { 26 | $queryStringParams[] = $propName . '=' . ($value ? 'true' : 'false'); 27 | } else { 28 | $queryStringParams[] = $propName . '=' . rawurlencode($value); 29 | } 30 | } 31 | 32 | return join('&', $queryStringParams); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/RequestQueryParams/GetAllSubscriptionStatusesQueryParams.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | protected array $status = []; 15 | } 16 | -------------------------------------------------------------------------------- /src/RequestQueryParams/GetNotificationHistoryQueryParams.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/RequestQueryParams/PageableQueryParams.php: -------------------------------------------------------------------------------- 1 | revision = $revision; 21 | return $newQueryParams; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/Response/MassExtendRenewalDateResponse.php: -------------------------------------------------------------------------------- 1 | requestIdentifier; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /src/Response/SendTestNotificationResponse.php: -------------------------------------------------------------------------------- 1 | testNotificationToken; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/TransactionInfo.php: -------------------------------------------------------------------------------- 1 | $rawTransactionInfo 257 | */ 258 | public static function createFromRawTransactionInfo(array $rawTransactionInfo): self 259 | { 260 | $transactionInfo = new self(); 261 | 262 | $typeCaster = (new ArrayTypeCaseGenerator())($rawTransactionInfo, [ 263 | 'int' => [ 264 | '?expiresDate', '?offerType', 'originalPurchaseDate', '?price', 'purchaseDate', 265 | 'quantity', '?revocationDate', '?revocationReason', 'signedDate', 266 | ], 267 | 'bool' => [ 268 | '?isUpgraded', 269 | ], 270 | 'string' => [ 271 | '?appAccountToken', '?appTransactionId', 'bundleId', '?currency', 'environment', 'inAppOwnershipType', 272 | '?offerDiscountType', '?offerIdentifier', 'originalTransactionId', 'productId', '?storefront', 273 | '?storefrontId', '?subscriptionGroupIdentifier', 'transactionId', '?transactionReason', 274 | 'type', '?webOrderLineItemId', 275 | ], 276 | ]); 277 | 278 | foreach ($typeCaster as $prop => $value) { 279 | $transactionInfo->$prop = $value; 280 | } 281 | 282 | return $transactionInfo; 283 | } 284 | 285 | /** 286 | * Returns a UUID that associates the transaction with a user on your own service. 287 | * If the app doesn't provide an appAccountToken, this string is empty. 288 | */ 289 | public function getAppAccountToken(): ?string 290 | { 291 | return $this->appAccountToken; 292 | } 293 | 294 | /** 295 | * Returns the unique identifier of the app download transaction. 296 | */ 297 | public function getAppTransactionId(): ?string 298 | { 299 | return $this->appTransactionId; 300 | } 301 | 302 | /** 303 | * Returns the bundle identifier of the app. 304 | */ 305 | public function getBundleId(): string 306 | { 307 | return $this->bundleId; 308 | } 309 | 310 | /** 311 | * Returns the three-letter ISO 4217 currency code for the price of the product. 312 | */ 313 | public function getCurrency(): ?string 314 | { 315 | return $this->currency; 316 | } 317 | 318 | /** 319 | * Returns the server environment, either sandbox or production. 320 | * 321 | * @return Environment::PRODUCTION|Environment::SANDBOX 322 | */ 323 | public function getEnvironment(): string 324 | { 325 | return $this->environment; 326 | } 327 | 328 | /** 329 | * Returns the UNIX time, in milliseconds, the subscription expires or renews (if any). 330 | */ 331 | public function getExpiresDate(): ?int 332 | { 333 | return $this->expiresDate; 334 | } 335 | 336 | /** 337 | * Returns a string that describes whether the transaction was purchased by the user, or is available to them 338 | * through Family Sharing. 339 | * 340 | * @return self::IN_APP_OWNERSHIP_TYPE__* 341 | */ 342 | public function getInAppOwnershipType(): string 343 | { 344 | return $this->inAppOwnershipType; 345 | } 346 | 347 | /** 348 | * Returns a Boolean value that indicates whether the user upgraded to another subscription. 349 | */ 350 | public function getIsUpgraded(): ?bool 351 | { 352 | return $this->isUpgraded; 353 | } 354 | 355 | /** 356 | * Returns the identifier that contains the promo code or the promotional offer identifier. 357 | * 358 | * NOTE: This field applies only when the offerType is either promotional offer or subscription offer code. 359 | */ 360 | public function getOfferIdentifier(): ?string 361 | { 362 | return $this->offerIdentifier; 363 | } 364 | 365 | /** 366 | * Returns a value that represents the offer discount type (if any). 367 | * 368 | * @return null|self::OFFER_DISCOUNT_TYPE__* 369 | */ 370 | public function getOfferDiscountType(): ?string 371 | { 372 | return $this->offerDiscountType; 373 | } 374 | 375 | /** 376 | * Returns a value that represents the promotional offer type (if any). 377 | * 378 | * @return null|self::OFFER_TYPE__* 379 | */ 380 | public function getOfferType(): ?int 381 | { 382 | /** @phpstan-ignore-next-line */ 383 | return $this->offerType; 384 | } 385 | 386 | /** 387 | * Returns the UNIX time, in milliseconds, that represents the purchase date of the original transaction identifier. 388 | */ 389 | public function getOriginalPurchaseDate(): int 390 | { 391 | return $this->originalPurchaseDate; 392 | } 393 | 394 | /** 395 | * Returns the transaction identifier of the original purchase. 396 | */ 397 | public function getOriginalTransactionId(): string 398 | { 399 | return $this->originalTransactionId; 400 | } 401 | 402 | /** 403 | * Returns the price multiplied by 1000 of the in-app purchase or subscription offer that you configured in App Store 404 | * Connect, as an integer. 405 | */ 406 | public function getPrice(): ?int 407 | { 408 | return $this->price; 409 | } 410 | 411 | /** 412 | * Returns the product identifier of the in-app purchase. 413 | */ 414 | public function getProductId(): string 415 | { 416 | return $this->productId; 417 | } 418 | 419 | /** 420 | * Returns the UNIX time, in milliseconds, that the App Store charged the user's account for a purchase, 421 | * restored product, subscription, or subscription renewal after a lapse. 422 | */ 423 | public function getPurchaseDate(): int 424 | { 425 | return $this->purchaseDate; 426 | } 427 | 428 | /** 429 | * Returns the number of consumable products the user purchased. 430 | */ 431 | public function getQuantity(): int 432 | { 433 | return $this->quantity; 434 | } 435 | 436 | /** 437 | * Returns the UNIX time, in milliseconds, that the App Store refunded the transaction or revoked it from 438 | * Family Sharing (if any). 439 | */ 440 | public function getRevocationDate(): ?int 441 | { 442 | return $this->revocationDate; 443 | } 444 | 445 | /** 446 | * The reason that the App Store refunded the transaction or revoked it from Family Sharing (if any). 447 | * 448 | * @return null|self::REVOCATION_REASON__* 449 | */ 450 | public function getRevocationReason(): ?int 451 | { 452 | /** @phpstan-ignore-next-line */ 453 | return $this->revocationReason; 454 | } 455 | 456 | /** 457 | * Returns the UNIX time, in milliseconds, that the App Store signed the JSON Web Signature (JWS) data. 458 | */ 459 | public function getSignedDate(): int 460 | { 461 | return $this->signedDate; 462 | } 463 | 464 | /** 465 | * Returns the three-letter code that represents the country or region associated with the App Store storefront for 466 | * the purchase. 467 | */ 468 | public function getStorefront(): ?string 469 | { 470 | return $this->storefront; 471 | } 472 | 473 | /** 474 | * Returns an Apple-defined value that uniquely identifies the App Store storefront associated with the purchase. 475 | */ 476 | public function getStorefrontId(): ?string 477 | { 478 | return $this->storefrontId; 479 | } 480 | 481 | /** 482 | * Returns the identifier of the subscription group the subscription belongs to (if any). 483 | */ 484 | public function getSubscriptionGroupIdentifier(): ?string 485 | { 486 | return $this->subscriptionGroupIdentifier; 487 | } 488 | 489 | /** 490 | * Returns the unique identifier of the transaction. 491 | */ 492 | public function getTransactionId(): string 493 | { 494 | return $this->transactionId; 495 | } 496 | 497 | /** 498 | * Returns the type of the in-app purchase. 499 | * 500 | * @return self::TRANSACTION_REASON__* 501 | */ 502 | public function getTransactionReason(): ?string 503 | { 504 | return $this->transactionReason; 505 | } 506 | 507 | /** 508 | * Returns the type of the in-app purchase. 509 | * 510 | * @return self::TYPE__* 511 | */ 512 | public function getType(): string 513 | { 514 | return $this->type; 515 | } 516 | 517 | /** 518 | * Returns the unique identifier of subscription purchase events across devices, including subscription 519 | * renewals (if any). 520 | */ 521 | public function getWebOrderLineItemId(): ?string 522 | { 523 | return $this->webOrderLineItemId; 524 | } 525 | 526 | /** 527 | * @return array 528 | */ 529 | public function jsonSerialize(): array 530 | { 531 | return get_object_vars($this); 532 | } 533 | } 534 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/Util/ArrayTypeCaseGenerator.php: -------------------------------------------------------------------------------- 1 | $input 12 | * @param array> $typeCastMap 13 | */ 14 | public function __invoke(array $input, array $typeCastMap): Generator 15 | { 16 | foreach ($typeCastMap as $type => $keys) { 17 | foreach ($keys as $key) { 18 | $isNullable = $key[0] === '?'; 19 | 20 | if ($isNullable) { 21 | $key = substr($key, 1); 22 | } 23 | 24 | if (!array_key_exists($key, $input)) { 25 | continue; 26 | } 27 | 28 | if ($input[$key] === null && $isNullable) { 29 | yield $key => null; 30 | 31 | continue; 32 | } 33 | 34 | switch ($type) { 35 | case 'int': 36 | yield $key => (int) $input[$key]; 37 | 38 | break; 39 | 40 | case 'bool': 41 | yield $key => (bool) $input[$key]; 42 | 43 | break; 44 | 45 | case 'float': 46 | yield $key => (float) $input[$key]; 47 | 48 | break; 49 | 50 | case 'string': 51 | yield $key => (string) $input[$key]; 52 | 53 | break; 54 | 55 | case 'array': 56 | yield $key => (array) $input[$key]; 57 | 58 | break; 59 | 60 | default: 61 | yield $key => null; 62 | } 63 | } 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Util/Helper.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 | -------------------------------------------------------------------------------- /tests/Unit/Util/ArrayTypeCaseGeneratorTest.php: -------------------------------------------------------------------------------- 1 | [ 15 | 'notNullableInt', '?nullableInt' 16 | ], 17 | 'bool' => [ 18 | 'notNullableBool', '?nullableBool' 19 | ], 20 | 'float' => [ 21 | 'notNullableFloat', '?nullableFloat', 22 | ], 23 | 'string' => [ 24 | 'notNullableString', '?nullableString', 25 | ], 26 | 'array' => [ 27 | 'notNullableArray', '?nullableArray', 28 | ], 29 | ]; 30 | 31 | $testInput = [ 32 | 'notNullableInt' => '1', 33 | 'nullableInt' => null, 34 | 'notNullableBool' => 1, 35 | 'nullableBool' => null, 36 | 'notNullableFloat' => '1.1', 37 | 'nullableFloat' => null, 38 | 'notNullableString' => 's', 39 | 'nullableString' => null, 40 | 'notNullableArray' => [1], 41 | 'nullableArray' => null, 42 | ]; 43 | $testOutput = []; 44 | $testTypeCaster = (new ArrayTypeCaseGenerator())($testInput, $testTypeCaseMap); 45 | 46 | foreach ($testTypeCaster as $prop => $value) { 47 | $testOutput[$prop] = $value; 48 | } 49 | 50 | $this->assertSame([ 51 | 'notNullableInt' => 1, 52 | 'nullableInt' => null, 53 | 'notNullableBool' => true, 54 | 'nullableBool' => null, 55 | 'notNullableFloat' => 1.1, 56 | 'nullableFloat' => null, 57 | 'notNullableString' => 's', 58 | 'nullableString' => null, 59 | 'notNullableArray' => [1], 60 | 'nullableArray' => null, 61 | ], $testOutput); 62 | } 63 | 64 | public function testArrayTypeCaseGeneratorNotNullables(): void 65 | { 66 | $testTypeCaseMap = [ 67 | 'int' => [ 68 | 'notNullableInt', 'nullableInt' 69 | ], 70 | 'bool' => [ 71 | 'notNullableBool', 'nullableBool' 72 | ], 73 | 'float' => [ 74 | 'notNullableFloat', 'nullableFloat', 75 | ], 76 | 'string' => [ 77 | 'notNullableString', 'nullableString', 78 | ], 79 | 'array' => [ 80 | 'notNullableArray', 'nullableArray', 81 | ], 82 | ]; 83 | 84 | $testInput = [ 85 | 'notNullableInt' => '1', 86 | 'nullableInt' => null, 87 | 'notNullableBool' => 1, 88 | 'nullableBool' => null, 89 | 'notNullableFloat' => '1.1', 90 | 'nullableFloat' => null, 91 | 'notNullableString' => 's', 92 | 'nullableString' => null, 93 | 'notNullableArray' => [1], 94 | 'nullableArray' => null, 95 | ]; 96 | $testOutput = []; 97 | $testTypeCaster = (new ArrayTypeCaseGenerator())($testInput, $testTypeCaseMap); 98 | 99 | foreach ($testTypeCaster as $prop => $value) { 100 | $testOutput[$prop] = $value; 101 | } 102 | 103 | $this->assertSame([ 104 | 'notNullableInt' => 1, 105 | 'nullableInt' => 0, 106 | 'notNullableBool' => true, 107 | 'nullableBool' => false, 108 | 'notNullableFloat' => 1.1, 109 | 'nullableFloat' => 0.0, 110 | 'notNullableString' => 's', 111 | 'nullableString' => '', 112 | 'notNullableArray' => [1], 113 | 'nullableArray' => [], 114 | ], $testOutput); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 |