├── .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 |