diff --git a/.github/workflows/ci-prb.yml b/.github/workflows/ci-prb.yml index d371e9c8..78842084 100644 --- a/.github/workflows/ci-prb.yml +++ b/.github/workflows/ci-prb.yml @@ -1,4 +1,6 @@ name: PR Builder +permissions: + contents: read on: pull_request: branches: [ main ] @@ -10,17 +12,15 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - python: [ "3.7", "3.8", "3.9", "3.10", "3.11" ] + python: [ "3.10", "3.11", "3.12", "3.13", "3.14" ] os: [ ubuntu-latest ] steps: - name: Checkout Code - uses: actions/checkout@v3 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Python ${{ matrix.python }} - uses: actions/setup-python@v4 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ matrix.python }} - - name: Print JDK Version - run: java -version - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/.github/workflows/ci-release-docs.yml b/.github/workflows/ci-release-docs.yml index 2f9f9f13..8125aa6a 100644 --- a/.github/workflows/ci-release-docs.yml +++ b/.github/workflows/ci-release-docs.yml @@ -1,34 +1,36 @@ name: Doc Builder +permissions: + contents: read on: release: types: [published] -permissions: - pages: write - id-token: write jobs: build: name: Python Doc Builder runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: - python-version: "3.11" + python-version: "3.14" - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements.txt pip install -r docs/requirements.txt - name: Sphinx Api Docs - run: sphinx-apidoc -F -H "App Store Server Library" -A "Apple Inc." -V "0.2.1" -e -a -o _staging . tests setup.py + run: sphinx-apidoc -F -H "App Store Server Library" -A "Apple Inc." -V "0.2.1" -e -a -o _staging . tests pyproject.toml - name: Spinx build run: sphinx-build -b html _staging _build - name: Upload docs - uses: actions/upload-pages-artifact@v2 + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: _build deploy: + permissions: + pages: write + id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} @@ -38,4 +40,4 @@ jobs: steps: - name: Deploy id: deployment - uses: actions/deploy-pages@v2 + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 diff --git a/.github/workflows/ci-release.yml b/.github/workflows/ci-release.yml index 9ac6488d..8fe60a00 100644 --- a/.github/workflows/ci-release.yml +++ b/.github/workflows/ci-release.yml @@ -1,17 +1,24 @@ name: Release Builder +permissions: + contents: read on: release: types: [published] jobs: build: + # Only non-pre-release builds trigger a release + if: "!github.event.release.prerelease" name: Python Release Builder runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: - python-version: "3.11" + python-version: "3.14" - name: Install build run: >- python3 -m @@ -23,6 +30,4 @@ jobs: python3 -m build - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - password: ${{ secrets.PYPI_API_TOKEN }} \ No newline at end of file + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 diff --git a/.github/workflows/ci-snapshot.yml b/.github/workflows/ci-snapshot.yml index ef298287..5e7a68a2 100644 --- a/.github/workflows/ci-snapshot.yml +++ b/.github/workflows/ci-snapshot.yml @@ -1,20 +1,21 @@ name: Snapshot Builder +permissions: + contents: read on: - push: - branches: [ main ] - tags-ignore: - - "[0-9]+.[0-9]+.[0-9]+" - - "[0-9]+.[0-9]+.[0-9]+.[0-9]+" + release: + types: [published] jobs: build: + # Pre-release builds trigger a snapshot being created + if: "github.event.release.prerelease" name: Python Snapshot Builder runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: - python-version: "3.11" + python-version: "3.14" - name: Install build run: >- python3 -m @@ -26,7 +27,7 @@ jobs: python3 -m build - name: Publish to Test PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 with: password: ${{ secrets.TEST_PYPI_API_TOKEN }} - repository-url: https://test.pypi.org/legacy/ \ No newline at end of file + repository-url: https://test.pypi.org/legacy/ diff --git a/CHANGELOG.md b/CHANGELOG.md index ec087e95..3263e304 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,85 @@ # Changelog +## Version 3.1.2 +- Support validity with SKEW checking with cryptography 43 methods [https://github.com/apple/app-store-server-library-python/pull/197] + +## Version 3.1.1 +- Fix inclusion of static fields in new Advanced Commerce API types [https://github.com/apple/app-store-server-library-python/pull/194] + +## Version 3.1.0 +- Incorporate changes for Advanced Commerce API, Retention Messaging API 1.5 and App Store Server API v1.21 [https://github.com/apple/app-store-server-library-python/pull/188] [https://github.com/apple/app-store-server-library-python/pull/189] from @riyazpanjwani +- Incorporate changes for App Store Server API v1.20 [https://github.com/apple/app-store-server-library-python/pull/191] from @riyazpanjwani +- Incorporate changes for Retention Messaging API v1.3 and 1.4 [https://github.com/apple/app-store-server-library-python/pull/186] +- Fix deliveryStatus not being included in consumption information [https://github.com/apple/app-store-server-library-python/pull/184] from @ohadbenita + +## Version 3.0.0 +- Incorporate changes for App Store Server API v1.19 [https://github.com/apple/app-store-server-library-python/pull/172] from @riyazpanjwani + - This changes ConsumptionRequest and several dependent types to the V2 variant, while the V1 version was created as a new type, to align with documentation, which is a breaking change + +## Version 2.0.0 +- Support Retention Messaging API [https://github.com/apple/app-store-server-library-python/pull/160] + - This changes internal details of BaseAppStoreServerAPIClient, which is a breaking change for subclassing clients +- Incorporate changes for App Store Server API v1.17 [https://github.com/apple/app-store-server-library-python/pull/162] from @riyazpanjwani +- Add a new VerificationStatus case for retryable OCSP network failures [https://github.com/apple/app-store-server-library-python/pull/163] +- Add timeout to the AppStoreServerAPIClient [https://github.com/apple/app-store-server-library-python/pull/164] +- Incorporate changes for App Store Server API v1.18 [https://github.com/apple/app-store-server-library-python/pull/166] from @izanger + - This changes OfferType's case SUBSCRIPTION_OFFER_CODE to OFFER_CODE, which is a breaking change + +## Version 1.9.0 +- Incorporate changes for App Store Server API v1.16 [https://github.com/apple/app-store-server-library-python/pull/141] from @riyazpanjwani +- Fix SyntaxWarning in regex pattern string [https://github.com/apple/app-store-server-library-python/pull/138] from @krepe90 + +## Version 1.8.0 +- Incorporate changes for App Store Server API v1.15 and App Store Server Notifications v2.15 [https://github.com/apple/app-store-server-library-python/pull/134] + +## Version 1.7.0 +- Update the SignedDataVerifier to cache verified certificate chains, improving performance [https://github.com/apple/app-store-server-library-python/pull/122] + +## Version 1.6.0 +- Update README to improve Dependabot link discovery [https://github.com/apple/app-store-server-library-python/pull/119] + +## Version 1.5.0 +- Add an async client built on httpx [https://github.com/apple/app-store-server-library-python/pull/105] +- Drop Python 3.7 support [https://github.com/apple/app-store-server-library-python/pull/106] +- Add check for original transaction id in legacy receipts [https://github.com/apple/app-store-server-library-python/pull/104] from @willhnation + +## Version 1.4.0 +- Incorporate changes for App Store Server API v1.13 and App Store Server Notifications v2.13 [https://github.com/apple/app-store-server-library-python/pull/102] +- Remove the upper limit on libraries that are unlikely to break upon upgrade [https://github.com/apple/app-store-server-library-python/pull/101] + +## Version 1.3.0 +- Incorporate changes for App Store Server API v1.12 and App Store Server Notifications v2.12 [https://github.com/apple/app-store-server-library-python/pull/95] +- Fix deprecation warnings from cryptography [https://github.com/apple/app-store-server-library-python/pull/94] from @WFT +- Replace use of deprecated datetime.utcnow() [https://github.com/apple/app-store-server-library-python/pull/93] from @WFT +- Cache cattrs values to prevent memory leak [https://github.com/apple/app-store-server-library-python/pull/92] from @Reskov + +## Version 1.2.1 +- Fix issue with OfferType in JWSTransactionDecodedPayload [https://github.com/apple/app-store-server-library-python/pull/88] from @devinwang + +## Version 1.2.0 +- Incorporate changes for App Store Server API v1.11 and App Store Server Notifications v2.11 [https://github.com/apple/app-store-server-library-python/pull/85] +- Various documentation and quality of life improvements, including contributions from @CallumWatkins, @hakusai22, and @sunny-dubey + +## Version 1.1.0 +- Support App Store Server Notifications v2.10 [https://github.com/apple/app-store-server-library-python/pull/65] +- Bump cryptography and pyOpenSSL maximum versions [https://github.com/apple/app-store-server-library-python/pull/61]/[https://github.com/apple/app-store-server-library-python/pull/63] +- Require appAppleId in SignedDataVerifier for the Production environment [https://github.com/apple/app-store-server-library-python/pull/60] + +## 1.0.0 +- Add error message to APIException [https://github.com/apple/app-store-server-library-python/pull/52] + +## 0.3.0 +- Add missing status field to the Data model [https://github.com/apple/app-store-server-library-python/pull/33] +- Add error codes from App Store Server API v1.9 [https://github.com/apple/app-store-server-library-python/pull/39] +- Add new fields from App Store Server API v1.10 [https://github.com/apple/app-store-server-library-python/pull/46] +- Add support for reading unknown enum values [https://github.com/apple/app-store-server-library-python/pull/45] +- Add support for Xcode and LocalTesting environments [https://github.com/apple/app-store-server-library-python/pull/44] + ## 0.2.1 -- Add py.typed file [#19] (https://github.com/apple/app-store-server-library-python/pull/19) -- Correct type annotation in PromotionalOfferSignatureCreator [#17] (https://github.com/apple/app-store-server-library-python/pull/17) +- Add py.typed file [https://github.com/apple/app-store-server-library-python/pull/19] +- Correct type annotation in PromotionalOfferSignatureCreator [https://github.com/apple/app-store-server-library-python/pull/17] ## 0.2.0 -- Correct type in LastTransactionsItem's status field [#11] (https://github.com/apple/app-store-server-library-python/pull/11) -- Fix default value None for fields should require an Optional type [#6] (https://github.com/apple/app-store-server-library-python/pull/6) \ No newline at end of file +- Correct type in LastTransactionsItem's status field [https://github.com/apple/app-store-server-library-python/pull/11] +- Fix default value None for fields should require an Optional type [https://github.com/apple/app-store-server-library-python/pull/6] diff --git a/NOTICE.txt b/NOTICE.txt index f6567a2b..1a415041 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -54,7 +54,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. _____________________ -requests contirbutors (requests) +requests contributors (requests) Apache License Version 2.0, January 2004 @@ -666,3 +666,19 @@ Permission is hereby granted, free of charge, to any person obtaining a copy of The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +_____________________ + +Encode OSS Ltd. (httpx) + +Copyright © 2019, Encode OSS Ltd. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/README.md b/README.md index 5b655a74..1fa307c5 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,20 @@ # Apple App Store Server Python Library -The Python server library for the [App Store Server API](https://developer.apple.com/documentation/appstoreserverapi) and [App Store Server Notifications](https://developer.apple.com/documentation/appstoreservernotifications). Also available in [Swift](https://github.com/apple/app-store-server-library-swift), [Node.js](https://github.com/apple/app-store-server-library-node), and [Java](https://github.com/apple/app-store-server-library-java). +The [Python](https://github.com/apple/app-store-server-library-python) server library for the [App Store Server API](https://developer.apple.com/documentation/appstoreserverapi), [App Store Server Notifications](https://developer.apple.com/documentation/appstoreservernotifications), [Retention Messaging API](https://developer.apple.com/documentation/retentionmessaging), and [Advanced Commerce API](https://developer.apple.com/documentation/AdvancedCommerceAPI). Also available in [Swift](https://github.com/apple/app-store-server-library-swift), [Node.js](https://github.com/apple/app-store-server-library-node), and [Java](https://github.com/apple/app-store-server-library-java). ## Table of Contents -1. [Beta](#-beta-) -2. [Installation](#installation) -3. [Documentation](#documentation) -4. [Usage](#usage) -5. [Support](#support) - -## ⚠️ Beta ⚠️ - -This software is currently in Beta testing. Therefore, it should only be used for testing purposes, like for the Sandbox environment. API signatures may change between releases and signature verification may receive security updates. +1. [Installation](#installation) +2. [Documentation](#documentation) +3. [Usage](#usage) +4. [Support](#support) ## Installation #### Requirements -- Python 3.7+ +- Python 3.8+ -### Gradle -```groovy +### pip +```sh pip install app-store-server-library ``` @@ -28,6 +23,15 @@ pip install app-store-server-library [Documentation](https://apple.github.io/app-store-server-library-python/) [WWDC Video](https://developer.apple.com/videos/play/wwdc2023/10143/) + +### Obtaining an In-App Purchase key from App Store Connect + +To use the App Store Server API or create promotional offer signatures, a signing key downloaded from App Store Connect is required. To obtain this key, you must have the Admin role. Go to Users and Access > Integrations > In-App Purchase. Here you can create and manage keys, as well as find your issuer ID. When using a key, you'll need the key ID and issuer ID as well. + +### Obtaining Apple Root Certificates + +Download and store the root certificates found in the Apple Root Certificates section of the [Apple PKI](https://www.apple.com/certificateauthority/) site. Provide these certificates as an array to a SignedDataVerifier to allow verifying the signed data comes from Apple. + ## Usage ### API Usage @@ -35,9 +39,8 @@ pip install app-store-server-library ```python from appstoreserverlibrary.api_client import AppStoreServerAPIClient, APIException from appstoreserverlibrary.models.Environment import Environment -from appstoreserverlibrary.models.SendTestNotificationResponse import SendTestNotificationResponse -private_key = read_private_key("/path/to/key/SubscriptionKey_ABCDEFGHIJ.p8") # Implemenation will vary +private_key = read_private_key("/path/to/key/SubscriptionKey_ABCDEFGHIJ.p8") # Implementation will vary key_id = "ABCDEFGHIJ" issuer_id = "99b16628-15e4-4668-972b-eeff55eeff55" @@ -63,11 +66,12 @@ root_certificates = load_root_certificates() enable_online_checks = True bundle_id = "com.example" environment = Environment.SANDBOX -signed_data_verifier = SignedDataVerifier(root_certificates, enable_online_checks, environment, bundle_id) +app_apple_id = None # appAppleId must be provided for the Production environment +signed_data_verifier = SignedDataVerifier(root_certificates, enable_online_checks, environment, bundle_id, app_apple_id) try: signed_notification = "ey.." - payload = signed_data_verifier.verify_and_decode_notification() + payload = signed_data_verifier.verify_and_decode_notification(signed_notification) print(payload) except VerificationException as e: print(e) @@ -76,13 +80,13 @@ except VerificationException as e: ### Receipt Usage ```python -from appstoreserverlibrary.api_client import AppStoreServerAPIClient, APIException +from appstoreserverlibrary.api_client import AppStoreServerAPIClient, APIException, GetTransactionHistoryVersion from appstoreserverlibrary.models.Environment import Environment from appstoreserverlibrary.receipt_utility import ReceiptUtility from appstoreserverlibrary.models.HistoryResponse import HistoryResponse from appstoreserverlibrary.models.TransactionHistoryRequest import TransactionHistoryRequest, ProductType, Order -private_key = read_private_key("/path/to/key/SubscriptionKey_ABCDEFGHIJ.p8") # Implemenation will vary +private_key = read_private_key("/path/to/key/SubscriptionKey_ABCDEFGHIJ.p8") # Implementation will vary key_id = "ABCDEFGHIJ" issuer_id = "99b16628-15e4-4668-972b-eeff55eeff55" @@ -105,7 +109,7 @@ try: ) while response == None or response.hasMore: revision = response.revision if response != None else None - response = client.get_transaction_history(transaction_id, revision, request) + response = client.get_transaction_history(transaction_id, revision, request, GetTransactionHistoryVersion.V2) for transaction in response.signedTransactions: transactions.append(transaction) print(transactions) @@ -120,7 +124,7 @@ except APIException as e: from appstoreserverlibrary.promotional_offer import PromotionalOfferSignatureCreator import time -private_key = read_private_key("/path/to/key/SubscriptionKey_ABCDEFGHIJ.p8") # Implemenation will vary +private_key = read_private_key("/path/to/key/SubscriptionKey_ABCDEFGHIJ.p8") # Implementation will vary key_id = "ABCDEFGHIJ" bundle_id = "com.example" @@ -135,6 +139,38 @@ timestamp = round(time.time()*1000) base64_encoded_signature = promotion_code_signature_generator.create_signature(product_id, subscription_offer_id, application_username, nonce, timestamp) ``` +### Async HTTPX Support + +#### Pip +Include the optional async dependency +```sh +pip install app-store-server-library[async] +``` + +#### API Usage +```python +from appstoreserverlibrary.api_client import AsyncAppStoreServerAPIClient, APIException +from appstoreserverlibrary.models.Environment import Environment + +private_key = read_private_key("/path/to/key/SubscriptionKey_ABCDEFGHIJ.p8") # Implementation will vary + +key_id = "ABCDEFGHIJ" +issuer_id = "99b16628-15e4-4668-972b-eeff55eeff55" +bundle_id = "com.example" +environment = Environment.SANDBOX + +client = AsyncAppStoreServerAPIClient(private_key, key_id, issuer_id, bundle_id, environment) + +try: + response = await client.request_test_notification() + print(response) +except APIException as e: + print(e) + +# Once client use is finished +await client.async_close() +``` + ## Support Only the latest major version of the library will receive updates, including security updates. Therefore, it is recommended to update to new major versions. diff --git a/appstoreserverlibrary/api_client.py b/appstoreserverlibrary/api_client.py index c219a630..0309ec93 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -2,8 +2,9 @@ import calendar import datetime -from enum import IntEnum -from typing import Any, Dict, List, Optional, Type, TypeVar, Union +import warnings +from enum import IntEnum, Enum +from typing import Any, Dict, List, MutableMapping, Optional, Type, TypeVar, Union from attr import define import requests @@ -14,82 +15,654 @@ from appstoreserverlibrary.models.LibraryUtility import _get_cattrs_converter from .models.CheckTestNotificationResponse import CheckTestNotificationResponse from .models.ConsumptionRequest import ConsumptionRequest - +from .models.ConsumptionRequestV1 import ConsumptionRequestV1 +from .models.DefaultConfigurationRequest import DefaultConfigurationRequest +from .models.DefaultConfigurationResponse import DefaultConfigurationResponse from .models.Environment import Environment from .models.ExtendRenewalDateRequest import ExtendRenewalDateRequest from .models.ExtendRenewalDateResponse import ExtendRenewalDateResponse +from .models.GetImageListResponse import GetImageListResponse +from .models.GetMessageListResponse import GetMessageListResponse from .models.HistoryResponse import HistoryResponse +from .models.ImageSize import ImageSize from .models.MassExtendRenewalDateRequest import MassExtendRenewalDateRequest from .models.MassExtendRenewalDateResponse import MassExtendRenewalDateResponse from .models.MassExtendRenewalDateStatusResponse import MassExtendRenewalDateStatusResponse from .models.NotificationHistoryRequest import NotificationHistoryRequest from .models.NotificationHistoryResponse import NotificationHistoryResponse from .models.OrderLookupResponse import OrderLookupResponse +from .models.PerformanceTestRequest import PerformanceTestRequest +from .models.PerformanceTestResponse import PerformanceTestResponse +from .models.PerformanceTestResultResponse import PerformanceTestResultResponse +from .models.RealtimeUrlRequest import RealtimeUrlRequest +from .models.RealtimeUrlResponse import RealtimeUrlResponse from .models.RefundHistoryResponse import RefundHistoryResponse from .models.SendTestNotificationResponse import SendTestNotificationResponse from .models.Status import Status from .models.StatusResponse import StatusResponse from .models.TransactionHistoryRequest import TransactionHistoryRequest from .models.TransactionInfoResponse import TransactionInfoResponse +from .models.AppTransactionInfoResponse import AppTransactionInfoResponse +from .models.UpdateAppAccountTokenRequest import UpdateAppAccountTokenRequest +from .models.UploadMessageRequestBody import UploadMessageRequestBody +from uuid import UUID T = TypeVar('T') class APIError(IntEnum): GENERAL_BAD_REQUEST = 4000000 + """ + An error that indicates an invalid request. + + https://developer.apple.com/documentation/appstoreserverapi/generalbadrequesterror + """ + INVALID_APP_IDENTIFIER = 4000002 + """ + An error that indicates an invalid app identifier. + + https://developer.apple.com/documentation/appstoreserverapi/invalidappidentifiererror + """ + INVALID_REQUEST_REVISION = 4000005 + """ + An error that indicates an invalid request revision. + + https://developer.apple.com/documentation/appstoreserverapi/invalidrequestrevisionerror + """ + INVALID_TRANSACTION_ID = 4000006 + """ + An error that indicates an invalid transaction identifier. + + https://developer.apple.com/documentation/appstoreserverapi/invalidtransactioniderror + """ + INVALID_ORIGINAL_TRANSACTION_ID = 4000008 + """ + An error that indicates an invalid original transaction identifier. + + https://developer.apple.com/documentation/appstoreserverapi/invalidoriginaltransactioniderror + """ + INVALID_EXTEND_BY_DAYS = 4000009 + """ + An error that indicates an invalid extend-by-days value. + + https://developer.apple.com/documentation/appstoreserverapi/invalidextendbydayserror + """ + INVALID_EXTEND_REASON_CODE = 4000010 - INVALID_IDENTIFIER = 4000011 + """ + An error that indicates an invalid reason code. + + https://developer.apple.com/documentation/appstoreserverapi/invalidextendreasoncodeerror + """ + + INVALID_REQUEST_IDENTIFIER = 4000011 + """ + An error that indicates an invalid request identifier. + + https://developer.apple.com/documentation/appstoreserverapi/invalidrequestidentifiererror + """ + START_DATE_TOO_FAR_IN_PAST = 4000012 + """ + An error that indicates that the start date is earlier than the earliest allowed date. + + https://developer.apple.com/documentation/appstoreserverapi/startdatetoofarinpasterror + """ + START_DATE_AFTER_END_DATE = 4000013 + """ + An error that indicates that the end date precedes the start date, or the two dates are equal. + + https://developer.apple.com/documentation/appstoreserverapi/startdateafterenddateerror + """ + INVALID_PAGINATION_TOKEN = 4000014 + """ + An error that indicates the pagination token is invalid. + + https://developer.apple.com/documentation/appstoreserverapi/invalidpaginationtokenerror + """ + INVALID_START_DATE = 4000015 + """ + An error that indicates the start date is invalid. + + https://developer.apple.com/documentation/appstoreserverapi/invalidstartdateerror + """ + INVALID_END_DATE = 4000016 + """ + An error that indicates the end date is invalid. + + https://developer.apple.com/documentation/appstoreserverapi/invalidenddateerror + """ + PAGINATION_TOKEN_EXPIRED = 4000017 + """ + An error that indicates the pagination token expired. + + https://developer.apple.com/documentation/appstoreserverapi/paginationtokenexpirederror + """ + INVALID_NOTIFICATION_TYPE = 4000018 + """ + An error that indicates the notification type or subtype is invalid. + + https://developer.apple.com/documentation/appstoreserverapi/invalidnotificationtypeerror + """ + MULTIPLE_FILTERS_SUPPLIED = 4000019 + """ + An error that indicates the request is invalid because it has too many constraints applied. + + https://developer.apple.com/documentation/appstoreserverapi/multiplefilterssuppliederror + """ + INVALID_TEST_NOTIFICATION_TOKEN = 4000020 + """ + An error that indicates the test notification token is invalid. + + https://developer.apple.com/documentation/appstoreserverapi/invalidtestnotificationtokenerror + """ + INVALID_SORT = 4000021 + """ + An error that indicates an invalid sort parameter. + + https://developer.apple.com/documentation/appstoreserverapi/invalidsorterror + """ + INVALID_PRODUCT_TYPE = 4000022 + """ + An error that indicates an invalid product type parameter. + + https://developer.apple.com/documentation/appstoreserverapi/invalidproducttypeerror + """ + INVALID_PRODUCT_ID = 4000023 + """ + An error that indicates the product ID parameter is invalid. + + https://developer.apple.com/documentation/appstoreserverapi/invalidproductiderror + """ + INVALID_SUBSCRIPTION_GROUP_IDENTIFIER = 4000024 + """ + An error that indicates an invalid subscription group identifier. + + https://developer.apple.com/documentation/appstoreserverapi/invalidsubscriptiongroupidentifiererror + """ + INVALID_EXCLUDE_REVOKED = 4000025 + """ + An error that indicates the query parameter exclude-revoked is invalid. + + https://developer.apple.com/documentation/appstoreserverapi/invalidexcluderevokederror + + .. deprecated:: 1.5 + """ + INVALID_IN_APP_OWNERSHIP_TYPE = 4000026 + """ + An error that indicates an invalid in-app ownership type parameter. + + https://developer.apple.com/documentation/appstoreserverapi/invalidinappownershiptypeerror + """ + INVALID_EMPTY_STOREFRONT_COUNTRY_CODE_LIST = 4000027 + """ + An error that indicates a required storefront country code is empty. + + https://developer.apple.com/documentation/appstoreserverapi/invalidemptystorefrontcountrycodelisterror + """ + INVALID_STOREFRONT_COUNTRY_CODE = 4000028 + """ + An error that indicates a storefront code is invalid. + + https://developer.apple.com/documentation/appstoreserverapi/invalidstorefrontcountrycodeerror + """ + INVALID_REVOKED = 4000030 + """ + An error that indicates the revoked parameter contains an invalid value. + + https://developer.apple.com/documentation/appstoreserverapi/invalidrevokederror + """ + INVALID_STATUS = 4000031 + """ + An error that indicates the status parameter is invalid. + + https://developer.apple.com/documentation/appstoreserverapi/invalidstatuserror + """ + INVALID_ACCOUNT_TENURE = 4000032 + """ + An error that indicates the value of the account tenure field is invalid. + + https://developer.apple.com/documentation/appstoreserverapi/invalidaccounttenureerror + """ + INVALID_APP_ACCOUNT_TOKEN = 4000033 + """ + An error that indicates the value of the app account token field is invalid. + + https://developer.apple.com/documentation/appstoreserverapi/invalidappaccounttokenerror + """ + INVALID_CONSUMPTION_STATUS = 4000034 + """ + An error that indicates the value of the consumption status field is invalid. + + https://developer.apple.com/documentation/appstoreserverapi/invalidconsumptionstatuserror + """ + INVALID_CUSTOMER_CONSENTED = 4000035 + """ + An error that indicates the customer consented field is invalid or doesn’t indicate that the customer consented. + + https://developer.apple.com/documentation/appstoreserverapi/invalidcustomerconsentederror + """ + INVALID_DELIVERY_STATUS = 4000036 + """ + An error that indicates the value in the delivery status field is invalid. + + https://developer.apple.com/documentation/appstoreserverapi/invaliddeliverystatuserror + """ + INVALID_LIFETIME_DOLLARS_PURCHASED = 4000037 + """ + An error that indicates the value in the lifetime dollars purchased field is invalid. + + https://developer.apple.com/documentation/appstoreserverapi/invalidlifetimedollarspurchasederror + """ + INVALID_LIFETIME_DOLLARS_REFUNDED = 4000038 + """ + An error that indicates the value in the lifetime dollars refunded field is invalid. + + https://developer.apple.com/documentation/appstoreserverapi/invalidlifetimedollarsrefundederror + """ + INVALID_PLATFORM = 4000039 + """ + An error that indicates the value in the platform field is invalid. + + https://developer.apple.com/documentation/appstoreserverapi/invalidplatformerror + """ + INVALID_PLAY_TIME = 4000040 + """ + An error that indicates the value in the playtime field is invalid. + + https://developer.apple.com/documentation/appstoreserverapi/invalidplaytimeerror + """ + INVALID_SAMPLE_CONTENT_PROVIDED = 4000041 + """ + An error that indicates the value in the sample content provided field is invalid. + + https://developer.apple.com/documentation/appstoreserverapi/invalidsamplecontentprovidederror + """ + INVALID_USER_STATUS = 4000042 + """ + An error that indicates the value in the user status field is invalid. + + https://developer.apple.com/documentation/appstoreserverapi/invaliduserstatuserror + """ + INVALID_TRANSACTION_NOT_CONSUMABLE = 4000043 + """ + An error that indicates the transaction identifier doesn’t represent a consumable in-app purchase. + + https://developer.apple.com/documentation/appstoreserverapi/invalidtransactionnotconsumableerror + + .. deprecated:: 1.11 + """ + + INVALID_TRANSACTION_TYPE_NOT_SUPPORTED = 4000047 + """ + An error that indicates the transaction identifier represents an unsupported in-app purchase type. + + https://developer.apple.com/documentation/appstoreserverapi/invalidtransactiontypenotsupportederror + """ + + APP_TRANSACTION_ID_NOT_SUPPORTED_ERROR = 4000048 + """ + An error that indicates the endpoint doesn't support an app transaction ID. + + https://developer.apple.com/documentation/appstoreserverapi/apptransactionidnotsupportederror + """ + + INVALID_IMAGE = 4000161 + """ + An error that indicates the image that's uploading is invalid. + + https://developer.apple.com/documentation/retentionmessaging/invalidimageerror + """ + + HEADER_TOO_LONG = 4000162 + """ + An error that indicates the header text is too long. + + https://developer.apple.com/documentation/retentionmessaging/headertoolongerror + """ + + BODY_TOO_LONG = 4000163 + """ + An error that indicates the body text is too long. + + https://developer.apple.com/documentation/retentionmessaging/bodytoolongerror + """ + + INVALID_LOCALE = 4000164 + """ + An error that indicates the locale is invalid. + + https://developer.apple.com/documentation/retentionmessaging/invalidlocaleerror + """ + + ALT_TEXT_TOO_LONG = 4000175 + """ + An error that indicates the alternative text for an image is too long. + + https://developer.apple.com/documentation/retentionmessaging/alttexttoolongerror + """ + + INVALID_APP_ACCOUNT_TOKEN_UUID_ERROR = 4000183 + """ + An error that indicates the app account token value is not a valid UUID. + + https://developer.apple.com/documentation/appstoreserverapi/invalidappaccounttokenuuiderror + """ + + FAMILY_TRANSACTION_NOT_SUPPORTED_ERROR = 4000185 + """ + An error that indicates the transaction is for a product the customer obtains through Family Sharing, + which the endpoint doesn’t support. + + https://developer.apple.com/documentation/appstoreserverapi/familytransactionnotsupportederror + """ + + TRANSACTION_ID_IS_NOT_ORIGINAL_TRANSACTION_ID_ERROR = 4000187 + """ + An error that indicates the endpoint expects an original transaction identifier. + + https://developer.apple.com/documentation/appstoreserverapi/transactionidisnotoriginaltransactioniderror + """ + + INVALID_PERFORMANCE_TEST_REQUEST = 4000211 + """ + An error the API returns that indicates the performance test request is invalid. + + https://developer.apple.com/documentation/retentionmessaging/invalidperformancetestrequesterror + """ + + INVALID_REQUEST_ID = 4000212 + """ + An error that indicates the request ID is invalid. + + https://developer.apple.com/documentation/retentionmessaging/invalidrequestiderror + """ + + EXISTING_PERFORMANCE_TEST_RUN = 4000213 + """ + An error that indicates an error with an existing test. + + https://developer.apple.com/documentation/retentionmessaging/existingperformancetestrunerror + """ + + BAD_REQUEST_REALTIME_URL = 4000215 + """ + An error that indicates the URL is invalid. + + https://developer.apple.com/documentation/retentionmessaging/badrequestrealtimeurlerror + """ + + BAD_REQUEST_IMAGE_SIZE = 4000216 + """ + An error that indicates the image size provided is invalid. + + https://developer.apple.com/documentation/retentionmessaging/badrequestimagesizeerror + """ + + BAD_REQUEST_TOO_MANY_BULLET_POINTS = 4000218 + """ + An error that indicates there are too many bullet points. + + https://developer.apple.com/documentation/retentionmessaging/badrequesttoomanybulletpointserror + """ + + BAD_REQUEST_BULLET_POINT_TEXT_TOO_LONG = 4000219 + """ + An error that indicates the text for a bullet point is too long. + + https://developer.apple.com/documentation/retentionmessaging/badrequestbulletpointtexttoolongerror + """ + + BAD_REQUEST_ABOVE_IMAGE_REQUIRES_AN_IMAGE = 4000224 + """ + An error that indicates that no image object is included, but the request indicates that the header should be placed above the image. + + https://developer.apple.com/documentation/retentionmessaging/badrequestaboveimagerequiresanimageerror + """ + SUBSCRIPTION_EXTENSION_INELIGIBLE = 4030004 + """ + An error that indicates the subscription doesn't qualify for a renewal-date extension due to its subscription state. + + https://developer.apple.com/documentation/appstoreserverapi/subscriptionextensionineligibleerror + """ + SUBSCRIPTION_MAX_EXTENSION = 4030005 + """ + An error that indicates the subscription doesn’t qualify for a renewal-date extension because it has already received the maximum extensions. + + https://developer.apple.com/documentation/appstoreserverapi/subscriptionmaxextensionerror + """ + FAMILY_SHARED_SUBSCRIPTION_EXTENSION_INELIGIBLE = 4030007 + """ + An error that indicates a subscription isn't directly eligible for a renewal date extension because the user obtained it through Family Sharing. + + https://developer.apple.com/documentation/appstoreserverapi/familysharedsubscriptionextensionineligibleerror + """ + + MAXIMUM_NUMBER_OF_IMAGES_REACHED = 4030014 + """ + An error that indicates when you reach the maximum number of uploaded images. + + https://developer.apple.com/documentation/retentionmessaging/maximumnumberofimagesreachederror + """ + + MAXIMUM_NUMBER_OF_MESSAGES_REACHED = 4030016 + """ + An error that indicates when you reach the maximum number of uploaded messages. + + https://developer.apple.com/documentation/retentionmessaging/maximumnumberofmessagesreachederror + """ + + MESSAGE_NOT_APPROVED = 4030017 + """ + An error that indicates the message isn't in the approved state, so you can't configure it as a default message. + + https://developer.apple.com/documentation/retentionmessaging/messagenotapprovederror + """ + + IMAGE_NOT_APPROVED = 4030018 + """ + An error that indicates the image isn't in the approved state, so you can't configure it as part of a default message. + + https://developer.apple.com/documentation/retentionmessaging/imagenotapprovederror + """ + + IMAGE_IN_USE = 4030019 + """ + An error that indicates the image is currently in use as part of a message, so you can't delete it. + + https://developer.apple.com/documentation/retentionmessaging/imageinuseerror + """ + + FORBIDDEN_NO_PASSING_TEST = 4030026 + """ + An error that indicates that passing a performance test is required before you can set a URL for the production environment. + + https://developer.apple.com/documentation/retentionmessaging/forbiddennopassingtesterror + """ + ACCOUNT_NOT_FOUND = 4040001 + """ + An error that indicates the App Store account wasn’t found. + + https://developer.apple.com/documentation/appstoreserverapi/accountnotfounderror + """ + ACCOUNT_NOT_FOUND_RETRYABLE = 4040002 + """ + An error response that indicates the App Store account wasn’t found, but you can try again. + + https://developer.apple.com/documentation/appstoreserverapi/accountnotfoundretryableerror + """ + APP_NOT_FOUND = 4040003 + """ + An error that indicates the app wasn’t found. + + https://developer.apple.com/documentation/appstoreserverapi/appnotfounderror + """ + APP_NOT_FOUND_RETRYABLE = 4040004 + """ + An error response that indicates the app wasn’t found, but you can try again. + + https://developer.apple.com/documentation/appstoreserverapi/appnotfoundretryableerror + """ + ORIGINAL_TRANSACTION_ID_NOT_FOUND = 4040005 + """ + An error that indicates an original transaction identifier wasn't found. + + https://developer.apple.com/documentation/appstoreserverapi/originaltransactionidnotfounderror + """ + ORIGINAL_TRANSACTION_ID_NOT_FOUND_RETRYABLE = 4040006 + """ + An error response that indicates the original transaction identifier wasn’t found, but you can try again. + + https://developer.apple.com/documentation/appstoreserverapi/originaltransactionidnotfoundretryableerror + """ + SERVER_NOTIFICATION_URL_NOT_FOUND = 4040007 + """ + An error that indicates that the App Store server couldn’t find a notifications URL for your app in this environment. + + https://developer.apple.com/documentation/appstoreserverapi/servernotificationurlnotfounderror + """ + TEST_NOTIFICATION_NOT_FOUND = 4040008 + """ + An error that indicates that the test notification token is expired or the test notification status isn’t available. + + https://developer.apple.com/documentation/appstoreserverapi/testnotificationnotfounderror + """ + STATUS_REQUEST_NOT_FOUND = 4040009 + """ + An error that indicates the server didn't find a subscription-renewal-date extension request for the request identifier and product identifier you provided. + + https://developer.apple.com/documentation/appstoreserverapi/statusrequestnotfounderror + """ + TRANSACTION_ID_NOT_FOUND = 4040010 + """ + An error that indicates a transaction identifier wasn't found. + + https://developer.apple.com/documentation/appstoreserverapi/transactionidnotfounderror + """ + + IMAGE_NOT_FOUND = 4040014 + """ + An error that indicates the system can't find the image identifier. + + https://developer.apple.com/documentation/retentionmessaging/imagenotfounderror + """ + + MESSAGE_NOT_FOUND = 4040015 + """ + An error that indicates the system can't find the message identifier. + + https://developer.apple.com/documentation/retentionmessaging/messagenotfounderror + """ + + PERFORMANCE_TEST_RUN_NOT_FOUND = 4040018 + """ + An error the API returns if the service can't find the specified test run. + + https://developer.apple.com/documentation/retentionmessaging/performancetestrunnotfounderror + """ + + APP_TRANSACTION_DOES_NOT_EXIST_ERROR = 4040019 + """ + An error response that indicates an app transaction doesn’t exist for the specified customer. + + https://developer.apple.com/documentation/appstoreserverapi/apptransactiondoesnotexisterror + """ + + DEFAULT_MESSAGE_NOT_FOUND = 4040020 + """ + An error that indicates a default message isn’t configured. + + https://developer.apple.com/documentation/retentionmessaging/defaultmessagenotfounderror + """ + + REALTIME_URL_NOT_FOUND = 4040021 + """ + An error that indicates that the URL for your endpoint isn’t configured. + + https://developer.apple.com/documentation/retentionmessaging/realtimeurlnotfounderror + """ + + IMAGE_ALREADY_EXISTS = 4090000 + """ + An error that indicates the image identifier already exists. + + https://developer.apple.com/documentation/retentionmessaging/imagealreadyexistserror + """ + + MESSAGE_ALREADY_EXISTS = 4090001 + """ + An error that indicates the message identifier already exists. + + https://developer.apple.com/documentation/retentionmessaging/messagealreadyexistserror + """ + RATE_LIMIT_EXCEEDED = 4290000 + """ + An error that indicates that the request exceeded the rate limit. + + https://developer.apple.com/documentation/appstoreserverapi/ratelimitexceedederror + """ + GENERAL_INTERNAL = 5000000 + """ + An error that indicates a general internal error. + + https://developer.apple.com/documentation/appstoreserverapi/generalinternalerror + """ + GENERAL_INTERNAL_RETRYABLE = 5000001 + """ + An error response that indicates an unknown error occurred, but you can try again. + + https://developer.apple.com/documentation/appstoreserverapi/generalinternalretryableerror + """ @define @@ -97,30 +670,46 @@ class APIException(Exception): http_status_code: int api_error: Optional[APIError] raw_api_error: Optional[int] + error_message: Optional[str] - def __init__(self, http_status_code: int, raw_api_error: Optional[int] = None): + def __init__(self, http_status_code: int, raw_api_error: Optional[int] = None, error_message: Optional[str] = None): self.http_status_code = http_status_code self.raw_api_error = raw_api_error self.api_error = None + self.error_message = error_message try: if raw_api_error is not None: self.api_error = APIError(raw_api_error) except ValueError: pass -class AppStoreServerAPIClient: +class GetTransactionHistoryVersion(str, Enum): + V1 = "v1" + """ + .. deprecated:: 1.3.0 + """ + + V2 = "v2" + +class BaseAppStoreServerAPIClient: def __init__(self, signing_key: bytes, key_id: str, issuer_id: str, bundle_id: str, environment: Environment): + if environment == Environment.XCODE: + raise ValueError("Xcode is not a supported environment for an AppStoreServerAPIClient") if environment == Environment.PRODUCTION: - self._base_url = "https://api.storekit.itunes.apple.com" + self._base_url = "https://api.storekit.apple.com" + elif environment == Environment.LOCAL_TESTING: + self._base_url = "https://local-testing-base-url" + elif environment == Environment.SANDBOX: + self._base_url = "https://api.storekit-sandbox.apple.com" else: - self._base_url = "https://api.storekit-sandbox.itunes.apple.com" + raise ValueError("Invalid environment provided") self._signing_key = serialization.load_pem_private_key(signing_key, password=None, backend=default_backend()) self._key_id = key_id self._issuer_id = issuer_id self._bundle_id = bundle_id def _generate_token(self) -> str: - future_time = datetime.datetime.utcnow() + datetime.timedelta(minutes=5) + future_time = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(minutes=5) return jwt.encode( { "bid": self._bundle_id, @@ -132,38 +721,61 @@ def _generate_token(self) -> str: algorithm="ES256", headers={"kid": self._key_id}, ) - - def _make_request(self, path: str, method: str, queryParameters: Dict[str, Union[str, List[str]]], body, destination_class: Type[T]) -> T: - url = self._base_url + path - c = _get_cattrs_converter(type(body)) if body != None else None - json = c.unstructure(body) if body != None else None - headers = { - 'User-Agent': "app-store-server-library/python/0.1", - 'Authorization': 'Bearer ' + self._generate_token(), + + def _get_full_url(self, path) -> str: + return self._base_url + path + + def _get_headers(self) -> Dict[str, str]: + return { + 'User-Agent': "app-store-server-library/python/3.1.2", + 'Authorization': f'Bearer {self._generate_token()}', 'Accept': 'application/json' } - - response = self._execute_request(method, url, queryParameters, headers, json) - if response.status_code >= 200 and response.status_code < 300: - if destination_class == None: + + def _get_request_json(self, body) -> Dict[str, Any]: + c = _get_cattrs_converter(type(body)) if body is not None else None + return c.unstructure(body) if body is not None else None + + def _parse_response(self, status_code: int, headers: MutableMapping, json_supplier, destination_class: Type[T]) -> T: + if 200 <= status_code < 300: + if destination_class is None: return c = _get_cattrs_converter(destination_class) - response_body = response.json() + response_body = json_supplier() return c.structure(response_body, destination_class) else: # Best effort parsing of the response body - if not 'content-type' in response.headers or response.headers['content-type'] != 'application/json': - raise APIException(response.status_code) + if not 'content-type' in headers or headers['content-type'] != 'application/json': + raise APIException(status_code) try: - response_body = response.json() - raise APIException(response.status_code, response_body['errorCode']) + response_body = json_supplier() + raise APIException(status_code, response_body['errorCode'], response_body['errorMessage']) except APIException as e: raise e - except Exception: - raise APIException(response.status_code) + except Exception as e: + raise APIException(status_code) from e - def _execute_request(self, method: str, url: str, params: Dict[str, Union[str, List[str]]], headers: Dict[str, str], json: Dict[str, Any]) -> requests.Response: - return requests.request(method, url, params=params, headers=headers, json=json) + +class AppStoreServerAPIClient(BaseAppStoreServerAPIClient): + def __init__(self, signing_key: bytes, key_id: str, issuer_id: str, bundle_id: str, environment: Environment): + super().__init__(signing_key=signing_key, key_id=key_id, issuer_id=issuer_id, bundle_id=bundle_id, environment=environment) + + def _make_request(self, path: str, method: str, queryParameters: Dict[str, Union[str, List[str]]], body, destination_class: Type[T], content_type: Optional[str] = None) -> T: + url = self._get_full_url(path) + headers = self._get_headers() + + if isinstance(body, bytes): + if content_type: + headers['Content-Type'] = content_type + response = self._execute_request(method, url, queryParameters, headers, None, body) + else: + json = self._get_request_json(body) + response = self._execute_request(method, url, queryParameters, headers, json, None) + + return self._parse_response(response.status_code, response.headers, lambda: response.json(), destination_class) + + def _execute_request(self, method: str, url: str, params: Dict[str, Union[str, List[str]]], headers: Dict[str, str], json: Optional[Dict[str, Any]], data: Optional[bytes]) -> requests.Response: + return requests.request(method, url, params=params, headers=headers, json=json, data=data, timeout=30) def extend_renewal_date_for_all_active_subscribers(self, mass_extend_renewal_date_request: MassExtendRenewalDateRequest) -> MassExtendRenewalDateResponse: """ @@ -174,7 +786,7 @@ def extend_renewal_date_for_all_active_subscribers(self, mass_extend_renewal_dat :return: A response that indicates the server successfully received the subscription-renewal-date extension request. :throws APIException: If a response was returned indicating the request could not be processed """ - return self._make_request("/inApps/v1/subscriptions/extend/mass", "POST", {}, mass_extend_renewal_date_request, MassExtendRenewalDateResponse) + return self._make_request("/inApps/v1/subscriptions/extend/mass", "POST", {}, mass_extend_renewal_date_request, MassExtendRenewalDateResponse, None) def extend_subscription_renewal_date(self, original_transaction_id: str, extend_renewal_date_request: ExtendRenewalDateRequest) -> ExtendRenewalDateResponse: """ @@ -186,40 +798,40 @@ def extend_subscription_renewal_date(self, original_transaction_id: str, extend_ :return: A response that indicates whether an individual renewal-date extension succeeded, and related details. :throws APIException: If a response was returned indicating the request could not be processed """ - return self._make_request("/inApps/v1/subscriptions/extend/" + original_transaction_id, "PUT", {}, extend_renewal_date_request, ExtendRenewalDateResponse) + return self._make_request(f"/inApps/v1/subscriptions/extend/{original_transaction_id}", "PUT", {}, extend_renewal_date_request, ExtendRenewalDateResponse, None) - def get_all_subscription_statuses(self, transaction_id: str, status: List[Status] = None) -> StatusResponse: + def get_all_subscription_statuses(self, any_transaction_id: str, status: Optional[List[Status]] = None) -> StatusResponse: """ Get the statuses for all of a customer's auto-renewable subscriptions in your app. https://developer.apple.com/documentation/appstoreserverapi/get_all_subscription_statuses - - :param transaction_id: The identifier of a transaction that belongs to the customer, and which may be an original transaction identifier. + + :param any_transaction_id: Any transactionId, originalTransactionId, or appTransactionId that belongs to the customer for your app. :param status: An optional filter that indicates the status of subscriptions to include in the response. Your query may specify more than one status query parameter. :return: A response that contains status information for all of a customer's auto-renewable subscriptions in your app. :throws APIException: If a response was returned indicating the request could not be processed """ queryParameters: Dict[str, List[str]] = dict() - if status != None: + if status is not None: queryParameters["status"] = [s.value for s in status] - - return self._make_request("/inApps/v1/subscriptions/" + transaction_id, "GET", queryParameters, None, StatusResponse) + + return self._make_request(f"/inApps/v1/subscriptions/{any_transaction_id}", "GET", queryParameters, None, StatusResponse, None) - def get_refund_history(self, transaction_id: str, revision: str) -> RefundHistoryResponse: + def get_refund_history(self, any_transaction_id: str, revision: Optional[str]) -> RefundHistoryResponse: """ Get a paginated list of all of a customer's refunded in-app purchases for your app. https://developer.apple.com/documentation/appstoreserverapi/get_refund_history - :param transaction_id: The identifier of a transaction that belongs to the customer, and which may be an original transaction identifier. - :param revision: A token you provide to get the next set of up to 20 transactions. All responses include a revision token. Use the revision token from the previous RefundHistoryResponse. + :param any_transaction_id: Any transactionId, originalTransactionId, or appTransactionId that belongs to the customer for your app. + :param revision: A token you provide to get the next set of up to 20 transactions. All responses include a revision token. Use the revision token from the previous RefundHistoryResponse. :return: A response that contains status information for all of a customer's auto-renewable subscriptions in your app. :throws APIException: If a response was returned indicating the request could not be processed """ queryParameters: Dict[str, List[str]] = dict() - if revision != None: + if revision is not None: queryParameters["revision"] = [revision] - - return self._make_request("/inApps/v2/refund/lookup/" + transaction_id, "GET", queryParameters, None, RefundHistoryResponse) + + return self._make_request(f"/inApps/v2/refund/lookup/{any_transaction_id}", "GET", queryParameters, None, RefundHistoryResponse, None) def get_status_of_subscription_renewal_date_extensions(self, request_identifier: str, product_id: str) -> MassExtendRenewalDateStatusResponse: """ @@ -227,11 +839,11 @@ def get_status_of_subscription_renewal_date_extensions(self, request_identifier: https://developer.apple.com/documentation/appstoreserverapi/get_status_of_subscription_renewal_date_extensions :param request_identifier: The UUID that represents your request to the Extend Subscription Renewal Dates for All Active Subscribers endpoint. - :param product_id: The product identifier of the auto-renewable subscription that you request a renewal-date extension for. + :param product_id: The product identifier of the auto-renewable subscription that you request a renewal-date extension for. :return: A response that indicates the current status of a request to extend the subscription renewal date to all eligible subscribers. :throws APIException: If a response was returned indicating the request could not be processed """ - return self._make_request("/inApps/v1/subscriptions/extend/mass/" + product_id + "/" + request_identifier, "GET", {}, None, MassExtendRenewalDateStatusResponse) + return self._make_request(f"/inApps/v1/subscriptions/extend/mass/{product_id}/{request_identifier}", "GET", {}, None, MassExtendRenewalDateStatusResponse, None) def get_test_notification_status(self, test_notification_token: str) -> CheckTestNotificationResponse: """ @@ -242,9 +854,9 @@ def get_test_notification_status(self, test_notification_token: str) -> CheckTes :return: A response that contains the contents of the test notification sent by the App Store server and the result from your server. :throws APIException: If a response was returned indicating the request could not be processed """ - return self._make_request("/inApps/v1/notifications/test/" + test_notification_token, "GET", {}, None, CheckTestNotificationResponse) + return self._make_request(f"/inApps/v1/notifications/test/{test_notification_token}", "GET", {}, None, CheckTestNotificationResponse, None) - def get_notification_history(self, pagination_token: str, notification_history_request: NotificationHistoryRequest) -> NotificationHistoryResponse: + def get_notification_history(self, pagination_token: Optional[str], notification_history_request: NotificationHistoryRequest) -> NotificationHistoryResponse: """ Get a list of notifications that the App Store server attempted to send to your server. https://developer.apple.com/documentation/appstoreserverapi/get_notification_history @@ -255,51 +867,53 @@ def get_notification_history(self, pagination_token: str, notification_history_r :throws APIException: If a response was returned indicating the request could not be processed """ queryParameters: Dict[str, List[str]] = dict() - if pagination_token != None: + if pagination_token is not None: queryParameters["paginationToken"] = [pagination_token] - return self._make_request("/inApps/v1/notifications/history", "POST", queryParameters, notification_history_request, NotificationHistoryResponse) + return self._make_request("/inApps/v1/notifications/history", "POST", queryParameters, notification_history_request, NotificationHistoryResponse, None) - def get_transaction_history(self, transaction_id: str, revision: Optional[str], transaction_history_request: TransactionHistoryRequest) -> HistoryResponse: + def get_transaction_history(self, any_transaction_id: str, revision: Optional[str], transaction_history_request: TransactionHistoryRequest, version: GetTransactionHistoryVersion = GetTransactionHistoryVersion.V1) -> HistoryResponse: """ Get a customer's in-app purchase transaction history for your app. https://developer.apple.com/documentation/appstoreserverapi/get_transaction_history - :param transaction_id: The identifier of a transaction that belongs to the customer, and which may be an original transaction identifier. - :param revision: A token you provide to get the next set of up to 20 transactions. All responses include a revision token. Note: For requests that use the revision token, include the same query parameters from the initial request. Use the revision token from the previous HistoryResponse. + :param any_transaction_id: Any transactionId, originalTransactionId, or appTransactionId that belongs to the customer for your app. + :param revision: A token you provide to get the next set of up to 20 transactions. All responses include a revision token. Note: For requests that use the revision token, include the same query parameters from the initial request. Use the revision token from the previous HistoryResponse. + :param transaction_history_request: The request parameters that includes the startDate,endDate,productIds,productTypes and optional query constraints. + :param version: The version of the Get Transaction History endpoint to use. V2 is recommended. :return: A response that contains the customer's transaction history for an app. :throws APIException: If a response was returned indicating the request could not be processed """ queryParameters: Dict[str, List[str]] = dict() - if revision != None: + if revision is not None: queryParameters["revision"] = [revision] - if transaction_history_request.startDate != None: + if transaction_history_request.startDate is not None: queryParameters["startDate"] = [str(transaction_history_request.startDate)] - if transaction_history_request.endDate != None: + if transaction_history_request.endDate is not None: queryParameters["endDate"] = [str(transaction_history_request.endDate)] - if transaction_history_request.productIds != None: + if transaction_history_request.productIds is not None: queryParameters["productId"] = transaction_history_request.productIds - if transaction_history_request.productTypes != None: + if transaction_history_request.productTypes is not None: queryParameters["productType"] = [product_type.value for product_type in transaction_history_request.productTypes] - if transaction_history_request.sort != None: + if transaction_history_request.sort is not None: queryParameters["sort"] = [transaction_history_request.sort.value] - if transaction_history_request.subscriptionGroupIdentifiers != None: + if transaction_history_request.subscriptionGroupIdentifiers is not None: queryParameters["subscriptionGroupIdentifier"] = transaction_history_request.subscriptionGroupIdentifiers - if transaction_history_request.inAppOwnershipType != None: + if transaction_history_request.inAppOwnershipType is not None: queryParameters["inAppOwnershipType"] = [transaction_history_request.inAppOwnershipType.value] - if transaction_history_request.revoked != None: + if transaction_history_request.revoked is not None: queryParameters["revoked"] = [str(transaction_history_request.revoked)] - return self._make_request("/inApps/v1/history/" + transaction_id, "GET", queryParameters, None, HistoryResponse) - + return self._make_request("/inApps/{}/history/{}".format(version.value, any_transaction_id), "GET", queryParameters, None, HistoryResponse, None) + def get_transaction_info(self, transaction_id: str) -> TransactionInfoResponse: """ Get information about a single transaction for your app. @@ -309,7 +923,7 @@ def get_transaction_info(self, transaction_id: str) -> TransactionInfoResponse: :return: A response that contains signed transaction information for a single transaction. :throws APIException: If a response was returned indicating the request could not be processed """ - return self._make_request("/inApps/v1/transactions/" + transaction_id, "GET", {}, None, TransactionInfoResponse) + return self._make_request(f"/inApps/v1/transactions/{transaction_id}", "GET", {}, None, TransactionInfoResponse, None) def look_up_order_id(self, order_id: str) -> OrderLookupResponse: """ @@ -320,7 +934,8 @@ def look_up_order_id(self, order_id: str) -> OrderLookupResponse: :return: A response that includes the order lookup status and an array of signed transactions for the in-app purchases in the order. :throws APIException: If a response was returned indicating the request could not be processed """ - return self._make_request("/inApps/v1/lookup/" + order_id, "GET", {}, None, OrderLookupResponse) + return self._make_request(f"/inApps/v1/lookup/{order_id}", "GET", {}, None, OrderLookupResponse, None) + def request_test_notification(self) -> SendTestNotificationResponse: """ Ask App Store Server Notifications to send a test notification to your server. @@ -329,15 +944,624 @@ def request_test_notification(self) -> SendTestNotificationResponse: :return: A response that contains the test notification token. :throws APIException: If a response was returned indicating the request could not be processed """ - return self._make_request("/inApps/v1/notifications/test", "POST", {}, None, SendTestNotificationResponse) + return self._make_request("/inApps/v1/notifications/test", "POST", {}, None, SendTestNotificationResponse, None) - def send_consumption_data(self, transaction_id: str, consumption_request: ConsumptionRequest): + def send_consumption_data(self, transaction_id: str, consumption_request: ConsumptionRequestV1): """ Send consumption information about a consumable in-app purchase to the App Store after your server receives a consumption request notification. - https://developer.apple.com/documentation/appstoreserverapi/send_consumption_information + https://developer.apple.com/documentation/appstoreserverapi/send-consumption-information-v1 + + .. deprecated:: + Use :func:`send_consumption_information` instead. :param transaction_id: The transaction identifier for which you're providing consumption information. You receive this identifier in the CONSUMPTION_REQUEST notification the App Store sends to your server. :param consumption_request: The request body containing consumption information. :raises APIException: If a response was returned indicating the request could not be processed """ - self._make_request("/inApps/v1/transactions/consumption/" + transaction_id, "PUT", {}, consumption_request, None) + warnings.warn("send_consumption_data is deprecated, use send_consumption_information instead", DeprecationWarning, stacklevel=2) + self._make_request(f"/inApps/v1/transactions/consumption/{transaction_id}", "PUT", {}, consumption_request, None, None) + + def send_consumption_information(self, transaction_id: str, consumption_request: ConsumptionRequest): + """ + Send consumption information about an In-App Purchase to the App Store after your server receives a consumption request notification. + https://developer.apple.com/documentation/appstoreserverapi/send-consumption-information + + :param transaction_id: The transaction identifier for which you're providing consumption information. You receive this identifier in the CONSUMPTION_REQUEST notification the App Store sends to your server's App Store Server Notifications V2 endpoint. + :param consumption_request: The request body containing consumption information. + :raises APIException: If a response was returned indicating the request could not be processed + """ + self._make_request(f"/inApps/v2/transactions/consumption/{transaction_id}", "PUT", {}, consumption_request, None, None) + + def set_app_account_token(self, original_transaction_id: str, update_app_account_token_request: UpdateAppAccountTokenRequest): + """ + Sets the app account token value for a purchase the customer makes outside your app, or updates its value in an existing transaction. + https://developer.apple.com/documentation/appstoreserverapi/set-app-account-token + + :param original_transaction_id The original transaction identifier of the transaction to receive the app account token update. + :param update_app_account_token_request The request body that contains a valid app account token value. + :raises APIException: If a response was returned indicating the request could not be processed + """ + self._make_request(f"/inApps/v1/transactions/{original_transaction_id}/appAccountToken", "PUT", {}, update_app_account_token_request, None, None) + + def upload_image(self, image_identifier: UUID, image: bytes, image_size: Optional[ImageSize] = None): + """ + Upload an image to use for retention messaging. + + :param image_identifier: A UUID you provide to uniquely identify the image you upload. + :param image: The image file to upload. + :param image_size: The size of the image you upload. + :raises APIException: If a response was returned indicating the request could not be processed + :see: https://developer.apple.com/documentation/retentionmessaging/upload-image + """ + query_parameters = {} + if image_size is not None: + query_parameters["imageSize"] = [image_size.value] + self._make_request(f"/inApps/v1/messaging/image/{image_identifier}", "PUT", query_parameters, image, None, "image/png") + + def delete_image(self, image_identifier: UUID): + """ + Delete a previously uploaded image. + + :param image_identifier: The identifier of the image to delete. + :raises APIException: If a response was returned indicating the request could not be processed + :see: https://developer.apple.com/documentation/retentionmessaging/delete-image + """ + self._make_request(f"/inApps/v1/messaging/image/{image_identifier}", "DELETE", {}, None, None, None) + + def get_image_list(self) -> GetImageListResponse: + """ + Get the image identifier and state for all uploaded images. + + :return: A response that contains status information for all images. + :raises APIException: If a response was returned indicating the request could not be processed + :see: https://developer.apple.com/documentation/retentionmessaging/get-image-list + """ + return self._make_request("/inApps/v1/messaging/image/list", "GET", {}, None, GetImageListResponse, None) + + def upload_message(self, message_identifier: UUID, upload_message_request_body: UploadMessageRequestBody): + """ + Upload a message to use for retention messaging. + + :param message_identifier: A UUID you provide to uniquely identify the message you upload. + :param upload_message_request_body: The message text to upload. + :raises APIException: If a response was returned indicating the request could not be processed + :see: https://developer.apple.com/documentation/retentionmessaging/upload-message + """ + self._make_request(f"/inApps/v1/messaging/message/{message_identifier}", "PUT", {}, upload_message_request_body, None, None) + + def delete_message(self, message_identifier: UUID): + """ + Delete a previously uploaded message. + + :param message_identifier: The identifier of the message to delete. + :raises APIException: If a response was returned indicating the request could not be processed + :see: https://developer.apple.com/documentation/retentionmessaging/delete-message + """ + self._make_request(f"/inApps/v1/messaging/message/{message_identifier}", "DELETE", {}, None, None, None) + + def get_message_list(self) -> GetMessageListResponse: + """ + Get the message identifier and state of all uploaded messages. + + :return: A response that contains status information for all messages. + :raises APIException: If a response was returned indicating the request could not be processed + :see: https://developer.apple.com/documentation/retentionmessaging/get-message-list + """ + return self._make_request("/inApps/v1/messaging/message/list", "GET", {}, None, GetMessageListResponse, None) + + def configure_default_message(self, product_id: str, locale: str, default_configuration_request: DefaultConfigurationRequest): + """ + Configure a default message for a specific product in a specific locale. + + :param product_id: The product identifier for the default configuration. + :param locale: The locale for the default configuration. + :param default_configuration_request: The request body that includes the message identifier to configure as the default message. + :raises APIException: If a response was returned indicating the request could not be processed + :see: https://developer.apple.com/documentation/retentionmessaging/configure-default-message + """ + self._make_request(f"/inApps/v1/messaging/default/{product_id}/{locale}", "PUT", {}, default_configuration_request, None, None) + + def delete_default_message(self, product_id: str, locale: str): + """ + Delete a default message for a product in a locale. + + :param product_id: The product ID of the default message configuration. + :param locale: The locale of the default message configuration. + :raises APIException: If a response was returned indicating the request could not be processed + :see: https://developer.apple.com/documentation/retentionmessaging/delete-default-message + """ + self._make_request(f"/inApps/v1/messaging/default/{product_id}/{locale}", "DELETE", {}, None, None, None) + + def get_default_message(self, product_id: str, locale: str) -> DefaultConfigurationResponse: + """ + Gets the default message for a specific product in a specific locale, if it’s configured. + + :param product_id: The product identifier of the message. + :param locale: The locale of the message. + :return: The response body that contains the default configuration information. + :raises APIException: If a response was returned indicating the request could not be processed + :see: https://developer.apple.com/documentation/retentionmessaging/get-default-message + """ + return self._make_request(f"/inApps/v1/messaging/default/{product_id}/{locale}", "GET", {}, None, DefaultConfigurationResponse, None) + + def configure_realtime_url(self, realtime_url_request: RealtimeUrlRequest): + """ + Configures the URL for your Get Retention Message endpoint in the sandbox and production environments. + + :param realtime_url_request: The request body that includes your endpoint’s URL. + :raises APIException: If a response was returned indicating the request could not be processed + :see: https://developer.apple.com/documentation/retentionmessaging/configure-realtime-url + """ + self._make_request("/inApps/v1/messaging/realtime/url", "PUT", {}, realtime_url_request, None, None) + + def delete_realtime_url(self): + """ + Deletes the URL for your Get Retention Message endpoint, in the sandbox or production environments. + + :raises APIException: If a response was returned indicating the request could not be processed + :see: https://developer.apple.com/documentation/retentionmessaging/delete-realtime-url + """ + self._make_request("/inApps/v1/messaging/realtime/url", "DELETE", {}, None, None, None) + + def get_realtime_url(self) -> RealtimeUrlResponse: + """ + Gets the URL for real-time messages that points to your Get Retention Message endpoint, which you previously configured. + + :return: The response body that contains the URL for your Get Retention Message endpoint. + :raises APIException: If a response was returned indicating the request could not be processed + :see: https://developer.apple.com/documentation/retentionmessaging/get-realtime-url + """ + return self._make_request("/inApps/v1/messaging/realtime/url", "GET", {}, None, RealtimeUrlResponse, None) + + def initiate_performance_test(self, performance_test_request: PerformanceTestRequest) -> PerformanceTestResponse: + """ + Initiates a performance test of your Get Retention Message endpoint in the sandbox environment. + + :param performance_test_request: The request body which specifies a transaction identifier of an In-App Purchase to use for this test. + :return: The performance test response object. + :raises APIException: If a response was returned indicating the request could not be processed + :see: https://developer.apple.com/documentation/retentionmessaging/initiate-performance-test + """ + return self._make_request("/inApps/v1/messaging/performanceTest", "POST", {}, performance_test_request, PerformanceTestResponse, None) + + def get_performance_test_results(self, request_id: str) -> PerformanceTestResultResponse: + """ + Gets the results of the performance test for the specified identifier. + + :param request_id: The ID of the performance test to return, which you receive in the PerformanceTestResponse when you call Initiate Performance Test. + :return: An object the API returns that describes the performance test results. + :raises APIException: If a response was returned indicating the request could not be processed + :see: https://developer.apple.com/documentation/retentionmessaging/get-performance-test-results + """ + return self._make_request(f"/inApps/v1/messaging/performanceTest/result/{request_id}", "GET", {}, None, PerformanceTestResultResponse, None) + + def get_app_transaction_info(self, any_transaction_id: str) -> AppTransactionInfoResponse: + """ + Get a customer's app transaction information for your app. + + :param any_transaction_id: Any transactionId, originalTransactionId, or appTransactionId that belongs to the customer for your app. + :return: A response that contains signed app transaction information for a customer. + :raises APIException: If a response was returned indicating the request could not be processed + :see: https://developer.apple.com/documentation/appstoreserverapi/get-app-transaction-info + """ + return self._make_request(f"/inApps/v1/transactions/appTransactions/{any_transaction_id}", "GET", {}, None, AppTransactionInfoResponse, None) + + def finish_transaction(self, transaction_id: str): + """ + Notifies the App Store server that your system has finished processing the customer's transaction. + https://developer.apple.com/documentation/appstoreserverapi/finish-transaction + + :param transaction_id The transaction identifier of the transaction to mark as finished. + :raises APIException: If a response was returned indicating the request could not be processed + """ + self._make_request(f"/inApps/v1/transactions/{transaction_id}/finish", "POST", {}, None, None, None) + +class AsyncAppStoreServerAPIClient(BaseAppStoreServerAPIClient): + def __init__(self, signing_key: bytes, key_id: str, issuer_id: str, bundle_id: str, environment: Environment): + super().__init__(signing_key=signing_key, key_id=key_id, issuer_id=issuer_id, bundle_id=bundle_id, environment=environment) + try: + import httpx + self.http_client = httpx.AsyncClient() + except: + raise ModuleNotFoundError("httpx not found but attempting to instantiate an async client") + + async def async_close(self): + await self.http_client.aclose() + + async def _make_request(self, path: str, method: str, queryParameters: Dict[str, Union[str, List[str]]], body, destination_class: Type[T], content_type: Optional[str] = None) -> T: + url = self._get_full_url(path) + headers = self._get_headers() + + if isinstance(body, bytes): + # For binary data like images + if content_type: + headers['Content-Type'] = content_type + response = await self._execute_request(method, url, queryParameters, headers, None, body) + else: + # For JSON data + json = self._get_request_json(body) + response = await self._execute_request(method, url, queryParameters, headers, json, None) + + return self._parse_response(response.status_code, response.headers, lambda: response.json(), destination_class) + + async def _execute_request(self, method: str, url: str, params: Dict[str, Union[str, List[str]]], headers: Dict[str, str], json: Optional[Dict[str, Any]], data: Optional[bytes]): + return await self.http_client.request(method, url, params=params, headers=headers, json=json, data=data, timeout=30) + + async def extend_renewal_date_for_all_active_subscribers(self, mass_extend_renewal_date_request: MassExtendRenewalDateRequest) -> MassExtendRenewalDateResponse: + """ + Uses a subscription's product identifier to extend the renewal date for all of its eligible active subscribers. + https://developer.apple.com/documentation/appstoreserverapi/extend_subscription_renewal_dates_for_all_active_subscribers + + :param mass_extend_renewal_date_request: The request body for extending a subscription renewal date for all of its active subscribers. + :return: A response that indicates the server successfully received the subscription-renewal-date extension request. + :throws APIException: If a response was returned indicating the request could not be processed + """ + return await self._make_request("/inApps/v1/subscriptions/extend/mass", "POST", {}, mass_extend_renewal_date_request, MassExtendRenewalDateResponse, None) + + async def extend_subscription_renewal_date(self, original_transaction_id: str, extend_renewal_date_request: ExtendRenewalDateRequest) -> ExtendRenewalDateResponse: + """ + Extends the renewal date of a customer's active subscription using the original transaction identifier. + https://developer.apple.com/documentation/appstoreserverapi/extend_a_subscription_renewal_date + + :param original_transaction_id: The original transaction identifier of the subscription receiving a renewal date extension. + :param extend_renewal_date_request: The request body containing subscription-renewal-extension data. + :return: A response that indicates whether an individual renewal-date extension succeeded, and related details. + :throws APIException: If a response was returned indicating the request could not be processed + """ + return await self._make_request(f"/inApps/v1/subscriptions/extend/{original_transaction_id}", "PUT", {}, extend_renewal_date_request, ExtendRenewalDateResponse, None) + + async def get_all_subscription_statuses(self, any_transaction_id: str, status: Optional[List[Status]] = None) -> StatusResponse: + """ + Get the statuses for all of a customer's auto-renewable subscriptions in your app. + https://developer.apple.com/documentation/appstoreserverapi/get_all_subscription_statuses + + :param any_transaction_id: Any transactionId, originalTransactionId, or appTransactionId that belongs to the customer for your app. + :param status: An optional filter that indicates the status of subscriptions to include in the response. Your query may specify more than one status query parameter. + :return: A response that contains status information for all of a customer's auto-renewable subscriptions in your app. + :throws APIException: If a response was returned indicating the request could not be processed + """ + queryParameters: Dict[str, List[str]] = dict() + if status is not None: + queryParameters["status"] = [s.value for s in status] + + return await self._make_request(f"/inApps/v1/subscriptions/{any_transaction_id}", "GET", queryParameters, None, StatusResponse, None) + + async def get_refund_history(self, any_transaction_id: str, revision: Optional[str]) -> RefundHistoryResponse: + """ + Get a paginated list of all of a customer's refunded in-app purchases for your app. + https://developer.apple.com/documentation/appstoreserverapi/get_refund_history + + :param any_transaction_id: Any transactionId, originalTransactionId, or appTransactionId that belongs to the customer for your app. + :param revision: A token you provide to get the next set of up to 20 transactions. All responses include a revision token. Use the revision token from the previous RefundHistoryResponse. + :return: A response that contains status information for all of a customer's auto-renewable subscriptions in your app. + :throws APIException: If a response was returned indicating the request could not be processed + """ + + queryParameters: Dict[str, List[str]] = dict() + if revision is not None: + queryParameters["revision"] = [revision] + + return await self._make_request(f"/inApps/v2/refund/lookup/{any_transaction_id}", "GET", queryParameters, None, RefundHistoryResponse, None) + + async def get_status_of_subscription_renewal_date_extensions(self, request_identifier: str, product_id: str) -> MassExtendRenewalDateStatusResponse: + """ + Checks whether a renewal date extension request completed, and provides the final count of successful or failed extensions. + https://developer.apple.com/documentation/appstoreserverapi/get_status_of_subscription_renewal_date_extensions + + :param request_identifier: The UUID that represents your request to the Extend Subscription Renewal Dates for All Active Subscribers endpoint. + :param product_id: The product identifier of the auto-renewable subscription that you request a renewal-date extension for. + :return: A response that indicates the current status of a request to extend the subscription renewal date to all eligible subscribers. + :throws APIException: If a response was returned indicating the request could not be processed + """ + return await self._make_request(f"/inApps/v1/subscriptions/extend/mass/{product_id}/{request_identifier}", "GET", {}, None, MassExtendRenewalDateStatusResponse, None) + + async def get_test_notification_status(self, test_notification_token: str) -> CheckTestNotificationResponse: + """ + Check the status of the test App Store server notification sent to your server. + https://developer.apple.com/documentation/appstoreserverapi/get_test_notification_status + + :param test_notification_token: The test notification token received from the Request a Test Notification endpoint + :return: A response that contains the contents of the test notification sent by the App Store server and the result from your server. + :throws APIException: If a response was returned indicating the request could not be processed + """ + return await self._make_request(f"/inApps/v1/notifications/test/{test_notification_token}", "GET", {}, None, CheckTestNotificationResponse, None) + + async def get_notification_history(self, pagination_token: Optional[str], notification_history_request: NotificationHistoryRequest) -> NotificationHistoryResponse: + """ + Get a list of notifications that the App Store server attempted to send to your server. + https://developer.apple.com/documentation/appstoreserverapi/get_notification_history + + :param pagination_token: An optional token you use to get the next set of up to 20 notification history records. All responses that have more records available include a paginationToken. Omit this parameter the first time you call this endpoint. + :param notification_history_request: The request body that includes the start and end dates, and optional query constraints. + :return: A response that contains the App Store Server Notifications history for your app. + :throws APIException: If a response was returned indicating the request could not be processed + """ + queryParameters: Dict[str, List[str]] = dict() + if pagination_token is not None: + queryParameters["paginationToken"] = [pagination_token] + + return await self._make_request("/inApps/v1/notifications/history", "POST", queryParameters, notification_history_request, NotificationHistoryResponse, None) + + async def get_transaction_history(self, any_transaction_id: str, revision: Optional[str], transaction_history_request: TransactionHistoryRequest, version: GetTransactionHistoryVersion = GetTransactionHistoryVersion.V1) -> HistoryResponse: + """ + Get a customer's in-app purchase transaction history for your app. + https://developer.apple.com/documentation/appstoreserverapi/get_transaction_history + + :param any_transaction_id: Any transactionId, originalTransactionId, or appTransactionId that belongs to the customer for your app. + :param revision: A token you provide to get the next set of up to 20 transactions. All responses include a revision token. Note: For requests that use the revision token, include the same query parameters from the initial request. Use the revision token from the previous HistoryResponse. + :param transaction_history_request: The request parameters that includes the startDate,endDate,productIds,productTypes and optional query constraints. + :param version: The version of the Get Transaction History endpoint to use. V2 is recommended. + :return: A response that contains the customer's transaction history for an app. + :throws APIException: If a response was returned indicating the request could not be processed + """ + queryParameters: Dict[str, List[str]] = dict() + if revision is not None: + queryParameters["revision"] = [revision] + + if transaction_history_request.startDate is not None: + queryParameters["startDate"] = [str(transaction_history_request.startDate)] + + if transaction_history_request.endDate is not None: + queryParameters["endDate"] = [str(transaction_history_request.endDate)] + + if transaction_history_request.productIds is not None: + queryParameters["productId"] = transaction_history_request.productIds + + if transaction_history_request.productTypes is not None: + queryParameters["productType"] = [product_type.value for product_type in transaction_history_request.productTypes] + + if transaction_history_request.sort is not None: + queryParameters["sort"] = [transaction_history_request.sort.value] + + if transaction_history_request.subscriptionGroupIdentifiers is not None: + queryParameters["subscriptionGroupIdentifier"] = transaction_history_request.subscriptionGroupIdentifiers + + if transaction_history_request.inAppOwnershipType is not None: + queryParameters["inAppOwnershipType"] = [transaction_history_request.inAppOwnershipType.value] + + if transaction_history_request.revoked is not None: + queryParameters["revoked"] = [str(transaction_history_request.revoked)] + + return await self._make_request("/inApps/" + version + "/history/" + any_transaction_id, "GET", queryParameters, None, HistoryResponse, None) + + async def get_transaction_info(self, transaction_id: str) -> TransactionInfoResponse: + """ + Get information about a single transaction for your app. + https://developer.apple.com/documentation/appstoreserverapi/get_transaction_info + + :param transaction_id The identifier of a transaction that belongs to the customer, and which may be an original transaction identifier. + :return: A response that contains signed transaction information for a single transaction. + :throws APIException: If a response was returned indicating the request could not be processed + """ + return await self._make_request(f"/inApps/v1/transactions/{transaction_id}", "GET", {}, None, TransactionInfoResponse, None) + + async def look_up_order_id(self, order_id: str) -> OrderLookupResponse: + """ + Get a customer's in-app purchases from a receipt using the order ID. + https://developer.apple.com/documentation/appstoreserverapi/look_up_order_id + + :param order_id: The order ID for in-app purchases that belong to the customer. + :return: A response that includes the order lookup status and an array of signed transactions for the in-app purchases in the order. + :throws APIException: If a response was returned indicating the request could not be processed + """ + return await self._make_request(f"/inApps/v1/lookup/{order_id}", "GET", {}, None, OrderLookupResponse, None) + async def request_test_notification(self) -> SendTestNotificationResponse: + """ + Ask App Store Server Notifications to send a test notification to your server. + https://developer.apple.com/documentation/appstoreserverapi/request_a_test_notification + + :return: A response that contains the test notification token. + :throws APIException: If a response was returned indicating the request could not be processed + """ + return await self._make_request("/inApps/v1/notifications/test", "POST", {}, None, SendTestNotificationResponse, None) + + async def send_consumption_data(self, transaction_id: str, consumption_request: ConsumptionRequestV1): + """ + Send consumption information about a consumable in-app purchase to the App Store after your server receives a consumption request notification. + https://developer.apple.com/documentation/appstoreserverapi/send-consumption-information-v1 + + .. deprecated:: + Use :func:`send_consumption_information` instead. + + :param transaction_id: The transaction identifier for which you're providing consumption information. You receive this identifier in the CONSUMPTION_REQUEST notification the App Store sends to your server. + :param consumption_request: The request body containing consumption information. + :raises APIException: If a response was returned indicating the request could not be processed + """ + warnings.warn("send_consumption_data is deprecated, use send_consumption_information instead", DeprecationWarning, stacklevel=2) + await self._make_request(f"/inApps/v1/transactions/consumption/{transaction_id}", "PUT", {}, consumption_request, None, None) + + async def send_consumption_information(self, transaction_id: str, consumption_request: ConsumptionRequest): + """ + Send consumption information about an In-App Purchase to the App Store after your server receives a consumption request notification. + https://developer.apple.com/documentation/appstoreserverapi/send-consumption-information + + :param transaction_id: The transaction identifier for which you're providing consumption information. You receive this identifier in the CONSUMPTION_REQUEST notification the App Store sends to your server's App Store Server Notifications V2 endpoint. + :param consumption_request: The request body containing consumption information. + :raises APIException: If a response was returned indicating the request could not be processed + """ + await self._make_request(f"/inApps/v2/transactions/consumption/{transaction_id}", "PUT", {}, consumption_request, None, None) + + async def set_app_account_token(self, original_transaction_id: str, update_app_account_token_request: UpdateAppAccountTokenRequest): + """ + Sets the app account token value for a purchase the customer makes outside your app, or updates its value in an existing transaction. + https://developer.apple.com/documentation/appstoreserverapi/set-app-account-token + + :param original_transaction_id The original transaction identifier of the transaction to receive the app account token update. + :param update_app_account_token_request The request body that contains a valid app account token value. + :raises APIException: If a response was returned indicating the request could not be processed + """ + await self._make_request(f"/inApps/v1/transactions/{original_transaction_id}/appAccountToken", "PUT", {}, update_app_account_token_request, None, None) + + async def upload_image(self, image_identifier: UUID, image: bytes, image_size: Optional[ImageSize] = None): + """ + Upload an image to use for retention messaging. + + :param image_identifier: A UUID you provide to uniquely identify the image you upload. + :param image: The image file to upload. + :param image_size: The optional size of the image. + :raises APIException: If a response was returned indicating the request could not be processed + :see: https://developer.apple.com/documentation/retentionmessaging/upload-image + """ + query_parameters = {} + if image_size is not None: + query_parameters["imageSize"] = [image_size.value] + await self._make_request(f"/inApps/v1/messaging/image/{image_identifier}", "PUT", query_parameters, image, None, "image/png") + + async def delete_image(self, image_identifier: UUID): + """ + Delete a previously uploaded image. + + :param image_identifier: The identifier of the image to delete. + :raises APIException: If a response was returned indicating the request could not be processed + :see: https://developer.apple.com/documentation/retentionmessaging/delete-image + """ + await self._make_request(f"/inApps/v1/messaging/image/{image_identifier}", "DELETE", {}, None, None, None) + + async def get_image_list(self) -> GetImageListResponse: + """ + Get the image identifier and state for all uploaded images. + + :return: A response that contains status information for all images. + :raises APIException: If a response was returned indicating the request could not be processed + :see: https://developer.apple.com/documentation/retentionmessaging/get-image-list + """ + return await self._make_request("/inApps/v1/messaging/image/list", "GET", {}, None, GetImageListResponse, None) + + async def upload_message(self, message_identifier: UUID, upload_message_request_body: UploadMessageRequestBody): + """ + Upload a message to use for retention messaging. + + :param message_identifier: A UUID you provide to uniquely identify the message you upload. + :param upload_message_request_body: The message text to upload. + :raises APIException: If a response was returned indicating the request could not be processed + :see: https://developer.apple.com/documentation/retentionmessaging/upload-message + """ + await self._make_request(f"/inApps/v1/messaging/message/{message_identifier}", "PUT", {}, upload_message_request_body, None, None) + + async def delete_message(self, message_identifier: UUID): + """ + Delete a previously uploaded message. + + :param message_identifier: The identifier of the message to delete. + :raises APIException: If a response was returned indicating the request could not be processed + :see: https://developer.apple.com/documentation/retentionmessaging/delete-message + """ + await self._make_request(f"/inApps/v1/messaging/message/{message_identifier}", "DELETE", {}, None, None, None) + + async def get_message_list(self) -> GetMessageListResponse: + """ + Get the message identifier and state of all uploaded messages. + + :return: A response that contains status information for all messages. + :raises APIException: If a response was returned indicating the request could not be processed + :see: https://developer.apple.com/documentation/retentionmessaging/get-message-list + """ + return await self._make_request("/inApps/v1/messaging/message/list", "GET", {}, None, GetMessageListResponse, None) + + async def configure_default_message(self, product_id: str, locale: str, default_configuration_request: DefaultConfigurationRequest): + """ + Configure a default message for a specific product in a specific locale. + + :param product_id: The product identifier for the default configuration. + :param locale: The locale for the default configuration. + :param default_configuration_request: The request body that includes the message identifier to configure as the default message. + :raises APIException: If a response was returned indicating the request could not be processed + :see: https://developer.apple.com/documentation/retentionmessaging/configure-default-message + """ + await self._make_request(f"/inApps/v1/messaging/default/{product_id}/{locale}", "PUT", {}, default_configuration_request, None, None) + + async def delete_default_message(self, product_id: str, locale: str): + """ + Delete a default message for a product in a locale. + + :param product_id: The product ID of the default message configuration. + :param locale: The locale of the default message configuration. + :raises APIException: If a response was returned indicating the request could not be processed + :see: https://developer.apple.com/documentation/retentionmessaging/delete-default-message + """ + await self._make_request(f"/inApps/v1/messaging/default/{product_id}/{locale}", "DELETE", {}, None, None, None) + + async def get_default_message(self, product_id: str, locale: str) -> DefaultConfigurationResponse: + """ + Get the default message for a specific product in a specific locale. + + :param product_id: The product identifier for the default configuration. + :param locale: The locale for the default configuration. + :return: A response that contains the default configuration information. + :raises APIException: If a response was returned indicating the request could not be processed + :see: https://developer.apple.com/documentation/retentionmessaging/get-default-message + """ + return await self._make_request(f"/inApps/v1/messaging/default/{product_id}/{locale}", "GET", {}, None, DefaultConfigurationResponse, None) + + async def configure_realtime_url(self, realtime_url_request: RealtimeUrlRequest): + """ + Configure the real-time URL for retention messaging. + + :param realtime_url_request: The request body that contains the real-time URL. + :raises APIException: If a response was returned indicating the request could not be processed + :see: https://developer.apple.com/documentation/retentionmessaging/configure-realtime-url + """ + await self._make_request("/inApps/v1/messaging/realtime/url", "PUT", {}, realtime_url_request, None, None) + + async def delete_realtime_url(self): + """ + Delete the real-time URL for retention messaging. + + :raises APIException: If a response was returned indicating the request could not be processed + :see: https://developer.apple.com/documentation/retentionmessaging/delete-realtime-url + """ + await self._make_request("/inApps/v1/messaging/realtime/url", "DELETE", {}, None, None, None) + + async def get_realtime_url(self) -> RealtimeUrlResponse: + """ + Get the real-time URL for retention messaging. + + :return: A response that contains the real-time URL information. + :raises APIException: If a response was returned indicating the request could not be processed + :see: https://developer.apple.com/documentation/retentionmessaging/get-realtime-url + """ + return await self._make_request("/inApps/v1/messaging/realtime/url", "GET", {}, None, RealtimeUrlResponse, None) + + async def initiate_performance_test(self, performance_test_request: PerformanceTestRequest) -> PerformanceTestResponse: + """ + Initiates a performance test of your Get Retention Message endpoint in the sandbox environment. + + :param performance_test_request: The request body which specifies a transaction identifier of an In-App Purchase to use for this test. + :return: The performance test response object. + :raises APIException: If a response was returned indicating the request could not be processed + :see: https://developer.apple.com/documentation/retentionmessaging/initiate-performance-test + """ + return await self._make_request("/inApps/v1/messaging/performanceTest", "POST", {}, performance_test_request, PerformanceTestResponse, None) + + async def get_performance_test_results(self, request_id: str) -> PerformanceTestResultResponse: + """ + Gets the results of the performance test for the specified identifier. + + :param request_id: The ID of the performance test to return, which you receive in the PerformanceTestResponse when you call Initiate Performance Test. + :return: An object the API returns that describes the performance test results. + :raises APIException: If a response was returned indicating the request could not be processed + :see: https://developer.apple.com/documentation/retentionmessaging/get-performance-test-results + """ + return await self._make_request(f"/inApps/v1/messaging/performanceTest/result/{request_id}", "GET", {}, None, PerformanceTestResultResponse, None) + + async def get_app_transaction_info(self, any_transaction_id: str) -> AppTransactionInfoResponse: + """ + Get a customer's app transaction information for your app. + + :param any_transaction_id: Any transactionId, originalTransactionId, or appTransactionId that belongs to the customer for your app. + :return: A response that contains signed app transaction information for a customer. + :raises APIException: If a response was returned indicating the request could not be processed + :see: https://developer.apple.com/documentation/appstoreserverapi/get-app-transaction-info + """ + return await self._make_request(f"/inApps/v1/transactions/appTransactions/{any_transaction_id}", "GET", {}, None, AppTransactionInfoResponse, None) + + async def finish_transaction(self, transaction_id: str): + """ + Notifies the App Store server that your system has finished processing the customer's transaction. + https://developer.apple.com/documentation/appstoreserverapi/finish-transaction + + :param transaction_id The transaction identifier of the transaction to mark as finished. + :raises APIException: If a response was returned indicating the request could not be processed + """ + await self._make_request(f"/inApps/v1/transactions/{transaction_id}/finish", "POST", {}, None, None, None) + diff --git a/appstoreserverlibrary/jws_signature_creator.py b/appstoreserverlibrary/jws_signature_creator.py new file mode 100644 index 00000000..491e9f8b --- /dev/null +++ b/appstoreserverlibrary/jws_signature_creator.py @@ -0,0 +1,140 @@ +# Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +import datetime +from typing import Any, Dict, Optional +import base64 +import json +import jwt +import uuid + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import serialization + +from appstoreserverlibrary.models.LibraryUtility import _get_cattrs_converter + +class AdvancedCommerceAPIInAppRequest: + def __init__(self): + pass + +class JWSSignatureCreator: + def __init__(self, audience: str, signing_key: bytes, key_id: str, issuer_id: str, bundle_id: str): + self._audience = audience + self._signing_key = serialization.load_pem_private_key(signing_key, password=None, backend=default_backend()) + self._key_id = key_id + self._issuer_id = issuer_id + self._bundle_id = bundle_id + + def _create_signature(self, feature_specific_claims: Dict[str, Any]) -> str: + claims = feature_specific_claims + current_time = datetime.datetime.now(datetime.timezone.utc) + + claims["bid"] = self._bundle_id + claims["iss"] = self._issuer_id + claims["aud"] = self._audience + claims["iat"] = current_time + claims["nonce"] = str(uuid.uuid4()) + + return jwt.encode(claims, + self._signing_key, + algorithm="ES256", + headers={"kid": self._key_id}, + ) + +class PromotionalOfferV2SignatureCreator(JWSSignatureCreator): + def __init__(self, signing_key: bytes, key_id: str, issuer_id: str, bundle_id: str): + """ + Create a PromotionalOfferV2SignatureCreator + + :param signing_key: Your private key downloaded from App Store Connect + :param key_id: Your private key ID from App Store Connect + :param issuer_id: Your issuer ID from the Keys page in App Store Connect + :param bundle_id: Your app's bundle ID + """ + super().__init__(audience="promotional-offer", signing_key=signing_key, key_id=key_id, issuer_id=issuer_id, bundle_id=bundle_id) + + def create_signature(self, product_id: str, offer_identifier: str, transaction_id: Optional[str]) -> str: + """ + Create a promotional offer V2 signature. + https://developer.apple.com/documentation/storekit/generating-jws-to-sign-app-store-requests + + :param product_id: The unique identifier of the product + :param offer_identifier: The promotional offer identifier that you set up in App Store Connect + :param transaction_id: The unique identifier of any transaction that belongs to the customer. You can use the customer's appTransactionId, even for customers who haven't made any In-App Purchases in your app. This field is optional, but recommended. + :return: The signed JWS. + """ + if product_id is None: + raise ValueError("product_id cannot be null") + if offer_identifier is None: + raise ValueError("offer_identifier cannot be null") + feature_specific_claims = { + "productId": product_id, + "offerIdentifier": offer_identifier + } + if transaction_id is not None: + feature_specific_claims["transactionId"] = transaction_id + return self._create_signature(feature_specific_claims=feature_specific_claims) + +class IntroductoryOfferEligibilitySignatureCreator(JWSSignatureCreator): + def __init__(self, signing_key: bytes, key_id: str, issuer_id: str, bundle_id: str): + """ + Create an IntroductoryOfferEligibilitySignatureCreator + + :param signing_key: Your private key downloaded from App Store Connect + :param key_id: Your private key ID from App Store Connect + :param issuer_id: Your issuer ID from the Keys page in App Store Connect + :param bundle_id: Your app's bundle ID + """ + super().__init__(audience="introductory-offer-eligibility", signing_key=signing_key, key_id=key_id, issuer_id=issuer_id, bundle_id=bundle_id) + + def create_signature(self, product_id: str, allow_introductory_offer: bool, transaction_id: str) -> str: + """ + Create an introductory offer eligibility signature. + https://developer.apple.com/documentation/storekit/generating-jws-to-sign-app-store-requests + + :param product_id: The unique identifier of the product + :param allow_introductory_offer: A boolean value that determines whether the customer is eligible for an introductory offer + :param transaction_id: The unique identifier of any transaction that belongs to the customer. You can use the customer's appTransactionId, even for customers who haven't made any In-App Purchases in your app. + :return: The signed JWS. + """ + if product_id is None: + raise ValueError("product_id cannot be null") + if allow_introductory_offer is None: + raise ValueError("allow_introductory_offer cannot be null") + if transaction_id is None: + raise ValueError("transaction_id cannot be null") + feature_specific_claims = { + "productId": product_id, + "allowIntroductoryOffer": allow_introductory_offer, + "transactionId": transaction_id + } + return self._create_signature(feature_specific_claims=feature_specific_claims) + +class AdvancedCommerceAPIInAppSignatureCreator(JWSSignatureCreator): + def __init__(self, signing_key: bytes, key_id: str, issuer_id: str, bundle_id: str): + """ + Create an AdvancedCommerceAPIInAppSignatureCreator + + :param signing_key: Your private key downloaded from App Store Connect + :param key_id: Your private key ID from App Store Connect + :param issuer_id: Your issuer ID from the Keys page in App Store Connect + :param bundle_id: Your app's bundle ID + """ + super().__init__(audience="advanced-commerce-api", signing_key=signing_key, key_id=key_id, issuer_id=issuer_id, bundle_id=bundle_id) + + def create_signature(self, advanced_commerce_in_app_request: AdvancedCommerceAPIInAppRequest) -> str: + """ + Create an Advanced Commerce in-app signed request. + https://developer.apple.com/documentation/storekit/generating-jws-to-sign-app-store-requests + + :param advanced_commerce_in_app_request: The request to be signed. + :return: The signed JWS. + """ + if advanced_commerce_in_app_request is None: + raise ValueError("advanced_commerce_in_app_request cannot be null") + c = _get_cattrs_converter(type(advanced_commerce_in_app_request)) + request = c.unstructure(advanced_commerce_in_app_request) + encoded_request = base64.b64encode(json.dumps(request).encode(encoding='utf-8')).decode('utf-8') + feature_specific_claims = { + "request": encoded_request + } + return self._create_signature(feature_specific_claims=feature_specific_claims) \ No newline at end of file diff --git a/appstoreserverlibrary/models/AbstractAdvancedCommerceBaseItem.py b/appstoreserverlibrary/models/AbstractAdvancedCommerceBaseItem.py new file mode 100644 index 00000000..87278533 --- /dev/null +++ b/appstoreserverlibrary/models/AbstractAdvancedCommerceBaseItem.py @@ -0,0 +1,18 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from abc import ABC + +from attr import define +import attr + +from .HelperValidationUtils import HelperValidationUtils +from .LibraryUtility import AttrsRawValueAware + +@define +class AbstractAdvancedCommerceBaseItem(AttrsRawValueAware, ABC): + SKU: str = attr.ib(validator=HelperValidationUtils.sku_validator) + """ + The product identifier of an in-app purchase product you manage in your own system. + + https://developer.apple.com/documentation/advancedcommerceapi/sku + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/AbstractAdvancedCommerceInAppRequest.py b/appstoreserverlibrary/models/AbstractAdvancedCommerceInAppRequest.py new file mode 100644 index 00000000..01295251 --- /dev/null +++ b/appstoreserverlibrary/models/AbstractAdvancedCommerceInAppRequest.py @@ -0,0 +1,14 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from abc import ABC + +from attr import define +import attr + +from .AdvancedCommerceRequest import AdvancedCommerceRequest +from ..jws_signature_creator import AdvancedCommerceAPIInAppRequest + +@define +class AbstractAdvancedCommerceInAppRequest(AdvancedCommerceRequest, AdvancedCommerceAPIInAppRequest, ABC): + operation: str = attr.ib() + version: str = attr.ib() \ No newline at end of file diff --git a/appstoreserverlibrary/models/AbstractAdvancedCommerceItem.py b/appstoreserverlibrary/models/AbstractAdvancedCommerceItem.py new file mode 100644 index 00000000..aefb8ef3 --- /dev/null +++ b/appstoreserverlibrary/models/AbstractAdvancedCommerceItem.py @@ -0,0 +1,23 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from attr import define +import attr + +from .AbstractAdvancedCommerceBaseItem import AbstractAdvancedCommerceBaseItem +from .HelperValidationUtils import HelperValidationUtils + +@define +class AbstractAdvancedCommerceItem(AbstractAdvancedCommerceBaseItem): + description: str = attr.ib(validator=HelperValidationUtils.description_validator) + """ + A string you provide that describes a SKU. + + https://developer.apple.com/documentation/advancedcommerceapi/description + """ + + displayName: str = attr.ib(validator=HelperValidationUtils.display_name_validator) + """ + A string with a product name that you can localize and is suitable for display to customers. + + https://developer.apple.com/documentation/advancedcommerceapi/displayname + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/AbstractAdvancedCommerceResponse.py b/appstoreserverlibrary/models/AbstractAdvancedCommerceResponse.py new file mode 100644 index 00000000..6f6125d2 --- /dev/null +++ b/appstoreserverlibrary/models/AbstractAdvancedCommerceResponse.py @@ -0,0 +1,23 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from abc import ABC +from typing import Optional + +from attr import define +import attr + +@define +class AbstractAdvancedCommerceResponse(ABC): + signedRenewalInfo: Optional[str] = attr.ib(default=None) + """ + Subscription renewal information, signed by the App Store, in JSON Web Signature (JWS) format. + + https://developer.apple.com/documentation/appstoreserverapi/jwsrenewalinfo + """ + + signedTransactionInfo: Optional[str] = attr.ib(default=None) + """ + Transaction information signed by the App Store, in JSON Web Signature (JWS) Compact Serialization format. + + https://developer.apple.com/documentation/appstoreserverapi/jwstransaction + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/AdvancedCommerceDescriptors.py b/appstoreserverlibrary/models/AdvancedCommerceDescriptors.py new file mode 100644 index 00000000..ff5a5eb4 --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceDescriptors.py @@ -0,0 +1,27 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from attr import define +import attr + +from .HelperValidationUtils import HelperValidationUtils + +@define +class AdvancedCommerceDescriptors: + """ + The display name and description of a subscription product. + + https://developer.apple.com/documentation/advancedcommerceapi/descriptors + """ + description: str = attr.ib(validator=HelperValidationUtils.description_validator) + """ + A string you provide that describes a SKU. + + https://developer.apple.com/documentation/advancedcommerceapi/description + """ + + displayName: str = attr.ib(validator=HelperValidationUtils.display_name_validator) + """ + A string with a product name that you can localize and is suitable for display to customers. + + https://developer.apple.com/documentation/advancedcommerceapi/displayname + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/AdvancedCommerceEffective.py b/appstoreserverlibrary/models/AdvancedCommerceEffective.py new file mode 100644 index 00000000..b694fa74 --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceEffective.py @@ -0,0 +1,14 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from enum import Enum + +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class AdvancedCommerceEffective(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): + """ + A string value that indicates when a requested change to an auto-renewable subscription goes into effect. + + https://developer.apple.com/documentation/advancedcommerceapi/effective + """ + IMMEDIATELY = "IMMEDIATELY" + NEXT_BILL_CYCLE = "NEXT_BILL_CYCLE" \ No newline at end of file diff --git a/appstoreserverlibrary/models/AdvancedCommerceInfo.py b/appstoreserverlibrary/models/AdvancedCommerceInfo.py new file mode 100644 index 00000000..e09d945f --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceInfo.py @@ -0,0 +1,29 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from typing import Optional +from uuid import UUID + +from attr import define +import attr + +@define +class AdvancedCommerceInfo: + """ + A response object you provide to present an offer or switch-plan recommendation message. + + https://developer.apple.com/documentation/retentionmessaging/advancedcommerceinfo + """ + + messageIdentifier: Optional[UUID] = attr.ib(default=None) + """ + The identifier of the message to display to the customer, along with the offer or switch-plan recommendation provided in advancedCommerceData. + + https://developer.apple.com/documentation/retentionmessaging/messageidentifier + """ + + advancedCommerceData: Optional[str] = attr.ib(default=None) + """ + A Base64-encoded JSON object which contains a JWS describing an offer or switch-plan recommendation. + + https://developer.apple.com/documentation/retentionmessaging/advancedcommercedata + """ diff --git a/appstoreserverlibrary/models/AdvancedCommerceOffer.py b/appstoreserverlibrary/models/AdvancedCommerceOffer.py new file mode 100644 index 00000000..0b18fba3 --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceOffer.py @@ -0,0 +1,50 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. +from typing import Optional + +from attr import define +import attr + +from .AdvancedCommerceOfferPeriod import AdvancedCommerceOfferPeriod +from .AdvancedCommerceOfferReason import AdvancedCommerceOfferReason +from .HelperValidationUtils import HelperValidationUtils +from .LibraryUtility import AttrsRawValueAware + +@define +class AdvancedCommerceOffer(AttrsRawValueAware): + """ + A discount offer for an auto-renewable subscription. + + https://developer.apple.com/documentation/advancedcommerceapi/offer + """ + + periodCount: int = attr.ib(validator=HelperValidationUtils.period_count_validator) + """ + The number of periods the offer is active. + """ + + price: int = attr.ib() + """ + The offer price, in milliunits. + + https://developer.apple.com/documentation/advancedcommerceapi/price + """ + + period: AdvancedCommerceOfferPeriod = AdvancedCommerceOfferPeriod.create_main_attr('rawPeriod', raw_required=True) + """ + The period of the offer. + """ + + rawPeriod: str = AdvancedCommerceOfferPeriod.create_raw_attr('period', required=True) + """ + See period + """ + + reason: AdvancedCommerceOfferReason = AdvancedCommerceOfferReason.create_main_attr('rawReason', raw_required=True) + """ + The reason for the offer. + """ + + rawReason: str = AdvancedCommerceOfferReason.create_raw_attr('reason', required=True) + """ + See reason + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/AdvancedCommerceOfferPeriod.py b/appstoreserverlibrary/models/AdvancedCommerceOfferPeriod.py new file mode 100644 index 00000000..0946b48f --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceOfferPeriod.py @@ -0,0 +1,21 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from enum import Enum + +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class AdvancedCommerceOfferPeriod(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): + """ + The period of the offer. + + https://developer.apple.com/documentation/advancedcommerceapi/offer + """ + P3D = "P3D" + P1W = "P1W" + P2W = "P2W" + P1M = "P1M" + P2M = "P2M" + P3M = "P3M" + P6M = "P6M" + P9M = "P9M" + P1Y = "P1Y" \ No newline at end of file diff --git a/appstoreserverlibrary/models/AdvancedCommerceOfferReason.py b/appstoreserverlibrary/models/AdvancedCommerceOfferReason.py new file mode 100644 index 00000000..0d4286e3 --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceOfferReason.py @@ -0,0 +1,15 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from enum import Enum + +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class AdvancedCommerceOfferReason(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): + """ + The reason for the offer. + + https://developer.apple.com/documentation/advancedcommerceapi/offer + """ + ACQUISITION = "ACQUISITION" + WIN_BACK = "WIN_BACK" + RETENTION = "RETENTION" \ No newline at end of file diff --git a/appstoreserverlibrary/models/AdvancedCommerceOneTimeChargeCreateRequest.py b/appstoreserverlibrary/models/AdvancedCommerceOneTimeChargeCreateRequest.py new file mode 100644 index 00000000..6c30527a --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceOneTimeChargeCreateRequest.py @@ -0,0 +1,55 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from typing import Optional + +from attr import define +import attr + +from .AbstractAdvancedCommerceInAppRequest import AbstractAdvancedCommerceInAppRequest +from .AdvancedCommerceOneTimeChargeItem import AdvancedCommerceOneTimeChargeItem + +@define +class AdvancedCommerceOneTimeChargeCreateRequest(AbstractAdvancedCommerceInAppRequest): + """ + The request data your app provides when a customer purchases a one-time-charge product. + + https://developer.apple.com/documentation/advancedcommerceapi/onetimechargecreaterequest + """ + + currency: str = attr.ib() + """ + The currency of the price of the product. + + https://developer.apple.com/documentation/advancedcommerceapi/currency + """ + + item: AdvancedCommerceOneTimeChargeItem = attr.ib() + """ + The details of the product for purchase. + + https://developer.apple.com/documentation/advancedcommerceapi/onetimechargeitem + """ + + taxCode: str = attr.ib() + """ + The tax code for this product. + + https://developer.apple.com/documentation/advancedcommerceapi/taxCode + """ + + operation: str = attr.ib(init=False, default="CREATE_ONE_TIME_CHARGE", on_setattr=attr.setters.frozen) + """ + The constant that represents the operation of this request. + """ + + version: str = attr.ib(init=False, default="1", on_setattr=attr.setters.frozen) + """ + The version number of the API. + """ + + storefront: Optional[str] = attr.ib(default=None) + """ + The storefront for the transaction. + + https://developer.apple.com/documentation/advancedcommerceapi/storefront + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/AdvancedCommerceOneTimeChargeItem.py b/appstoreserverlibrary/models/AdvancedCommerceOneTimeChargeItem.py new file mode 100644 index 00000000..514e8771 --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceOneTimeChargeItem.py @@ -0,0 +1,21 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from attr import define +import attr + +from .AbstractAdvancedCommerceItem import AbstractAdvancedCommerceItem + +@define +class AdvancedCommerceOneTimeChargeItem(AbstractAdvancedCommerceItem): + """ + The details of a one-time charge product, including its display name, price, SKU, and metadata. + + https://developer.apple.com/documentation/advancedcommerceapi/onetimechargeitem + """ + + price: int = attr.ib() + """ + The price, in milliunits of the currency, of the one-time charge product. + + https://developer.apple.com/documentation/advancedcommerceapi/price + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/AdvancedCommercePeriod.py b/appstoreserverlibrary/models/AdvancedCommercePeriod.py new file mode 100644 index 00000000..4b0a623e --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommercePeriod.py @@ -0,0 +1,19 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from enum import Enum + +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class AdvancedCommercePeriod(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): + """ + The duration of a single cycle of an auto-renewable subscription. + + https://developer.apple.com/documentation/advancedcommerceapi/period + """ + + P1W = "P1W" + P1M = "P1M" + P2M = "P2M" + P3M = "P3M" + P6M = "P6M" + P1Y = "P1Y" \ No newline at end of file diff --git a/appstoreserverlibrary/models/AdvancedCommercePriceIncreaseInfo.py b/appstoreserverlibrary/models/AdvancedCommercePriceIncreaseInfo.py new file mode 100644 index 00000000..e301786e --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommercePriceIncreaseInfo.py @@ -0,0 +1,35 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from typing import List, Optional + +from attr import define +import attr + +from .AdvancedCommercePriceIncreaseInfoStatus import AdvancedCommercePriceIncreaseInfoStatus +from .LibraryUtility import AttrsRawValueAware + +@define +class AdvancedCommercePriceIncreaseInfo(AttrsRawValueAware): + """ + https://developer.apple.com/documentation/appstoreserverapi/advancedcommercepriceincreaseinfo + """ + + dependentSKUs: Optional[List[str]] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/appstoreserverapi/advancedcommercepriceincreaseinfodependentsku + """ + + price: Optional[int] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/appstoreserverapi/advancedcommercepriceincreaseinfoprice + """ + + status: Optional[AdvancedCommercePriceIncreaseInfoStatus] = AdvancedCommercePriceIncreaseInfoStatus.create_main_attr('rawStatus') + """ + https://developer.apple.com/documentation/appstoreserverapi/advancedcommercepriceincreaseinfostatus + """ + + rawStatus: Optional[str] = AdvancedCommercePriceIncreaseInfoStatus.create_raw_attr('status') + """ + See status + """ diff --git a/appstoreserverlibrary/models/AdvancedCommercePriceIncreaseInfoStatus.py b/appstoreserverlibrary/models/AdvancedCommercePriceIncreaseInfoStatus.py new file mode 100644 index 00000000..dc55e831 --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommercePriceIncreaseInfoStatus.py @@ -0,0 +1,13 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from enum import Enum + +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class AdvancedCommercePriceIncreaseInfoStatus(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): + """ + https://developer.apple.com/documentation/appstoreservernotifications/advancedcommercepriceincreaseinfostatus + """ + SCHEDULED = "SCHEDULED" + PENDING = "PENDING" + ACCEPTED = "ACCEPTED" diff --git a/appstoreserverlibrary/models/AdvancedCommerceReason.py b/appstoreserverlibrary/models/AdvancedCommerceReason.py new file mode 100644 index 00000000..cb41c2c1 --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceReason.py @@ -0,0 +1,15 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from enum import Enum + +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class AdvancedCommerceReason(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): + """ + The data your app provides to change an item of an auto-renewable subscription. + + https://developer.apple.com/documentation/advancedcommerceapi/subscriptionmodifychangeitem + """ + UPGRADE = "UPGRADE" + DOWNGRADE = "DOWNGRADE" + APPLY_OFFER = "APPLY_OFFER" \ No newline at end of file diff --git a/appstoreserverlibrary/models/AdvancedCommerceRefund.py b/appstoreserverlibrary/models/AdvancedCommerceRefund.py new file mode 100644 index 00000000..c30e7e8a --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceRefund.py @@ -0,0 +1,46 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from typing import Optional + +from attr import define +import attr + +from .AdvancedCommerceRefundReason import AdvancedCommerceRefundReason +from .AdvancedCommerceRefundType import AdvancedCommerceRefundType +from .LibraryUtility import AttrsRawValueAware + +@define +class AdvancedCommerceRefund(AttrsRawValueAware): + """ + https://developer.apple.com/documentation/appstoreserverapi/advancedcommercerefund + """ + + refundAmount: Optional[int] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/appstoreserverapi/advancedcommercerefundamount + """ + + refundDate: Optional[int] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/appstoreserverapi/advancedcommercerefunddate + """ + + refundReason: Optional[AdvancedCommerceRefundReason] = AdvancedCommerceRefundReason.create_main_attr('rawRefundReason') + """ + https://developer.apple.com/documentation/appstoreserverapi/advancedcommercerefundreason + """ + + rawRefundReason: Optional[str] = AdvancedCommerceRefundReason.create_raw_attr('refundReason') + """ + See refundReason + """ + + refundType: Optional[AdvancedCommerceRefundType] = AdvancedCommerceRefundType.create_main_attr('rawRefundType') + """ + https://developer.apple.com/documentation/appstoreserverapi/advancedcommercerefundtype + """ + + rawRefundType: Optional[str] = AdvancedCommerceRefundType.create_raw_attr('refundType') + """ + See refundType + """ diff --git a/appstoreserverlibrary/models/AdvancedCommerceRefundReason.py b/appstoreserverlibrary/models/AdvancedCommerceRefundReason.py new file mode 100644 index 00000000..975db70d --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceRefundReason.py @@ -0,0 +1,20 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from enum import Enum + +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class AdvancedCommerceRefundReason(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): + """ + A reason to request a refund. + + https://developer.apple.com/documentation/advancedcommerceapi/refundreason + """ + + UNINTENDED_PURCHASE = "UNINTENDED_PURCHASE" + FULFILLMENT_ISSUE = "FULFILLMENT_ISSUE" + UNSATISFIED_WITH_PURCHASE = "UNSATISFIED_WITH_PURCHASE" + LEGAL = "LEGAL" + OTHER = "OTHER" + MODIFY_ITEMS_REFUND = "MODIFY_ITEMS_REFUND" + SIMULATE_REFUND_DECLINE = "SIMULATE_REFUND_DECLINE" \ No newline at end of file diff --git a/appstoreserverlibrary/models/AdvancedCommerceRefundType.py b/appstoreserverlibrary/models/AdvancedCommerceRefundType.py new file mode 100644 index 00000000..445a72d9 --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceRefundType.py @@ -0,0 +1,15 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from enum import Enum + +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class AdvancedCommerceRefundType(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): + """ + Information about the refund request for an item, such as its SKU, the refund amount, reason, and type. + + https://developer.apple.com/documentation/advancedcommerceapi/requestrefunditem + """ + FULL = "FULL" + PRORATED = "PRORATED" + CUSTOM = "CUSTOM" \ No newline at end of file diff --git a/appstoreserverlibrary/models/AdvancedCommerceRenewalInfo.py b/appstoreserverlibrary/models/AdvancedCommerceRenewalInfo.py new file mode 100644 index 00000000..591d96b9 --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceRenewalInfo.py @@ -0,0 +1,52 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from typing import List, Optional + +from attr import define +import attr + +from .AdvancedCommerceDescriptors import AdvancedCommerceDescriptors +from .AdvancedCommercePeriod import AdvancedCommercePeriod +from .AdvancedCommerceRenewalItem import AdvancedCommerceRenewalItem +from .LibraryUtility import AttrsRawValueAware + +@define +class AdvancedCommerceRenewalInfo(AttrsRawValueAware): + """ + https://developer.apple.com/documentation/appstoreserverapi/advancedcommercerenewalinfo + """ + + consistencyToken: Optional[str] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/appstoreserverapi/advancedcommerceconsistencytoken + """ + + descriptors: Optional[AdvancedCommerceDescriptors] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/appstoreserverapi/advancedcommercedescriptors + """ + + items: Optional[List[AdvancedCommerceRenewalItem]] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/appstoreserverapi/advancedcommercerenewalitem + """ + + period: Optional[AdvancedCommercePeriod] = AdvancedCommercePeriod.create_main_attr('rawPeriod') + """ + https://developer.apple.com/documentation/appstoreserverapi/advancedcommerceperiod + """ + + rawPeriod: Optional[str] = AdvancedCommercePeriod.create_raw_attr('period') + """ + See period + """ + + requestReferenceId: Optional[str] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/appstoreserverapi/advancedcommercerequestreferenceid + """ + + taxCode: Optional[str] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/appstoreserverapi/advancedcommercetaxcode + """ diff --git a/appstoreserverlibrary/models/AdvancedCommerceRenewalItem.py b/appstoreserverlibrary/models/AdvancedCommerceRenewalItem.py new file mode 100644 index 00000000..be435f8d --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceRenewalItem.py @@ -0,0 +1,45 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from typing import Optional + +from attr import define +import attr + +from .AdvancedCommerceOffer import AdvancedCommerceOffer +from .AdvancedCommercePriceIncreaseInfo import AdvancedCommercePriceIncreaseInfo + +@define +class AdvancedCommerceRenewalItem: + """ + https://developer.apple.com/documentation/appstoreserverapi/advancedcommercerenewalitem + """ + + SKU: Optional[str] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/appstoreserverapi/advancedcommercesku + """ + + description: Optional[str] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/appstoreserverapi/advancedcommercedescription + """ + + displayName: Optional[str] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/appstoreserverapi/advancedcommercedisplayname + """ + + offer: Optional[AdvancedCommerceOffer] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/appstoreserverapi/advancedcommerceoffer + """ + + price: Optional[int] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/appstoreserverapi/advancedcommerceprice + """ + + priceIncreaseInfo: Optional[AdvancedCommercePriceIncreaseInfo] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/appstoreserverapi/advancedcommercepriceincreaseinfo + """ diff --git a/appstoreserverlibrary/models/AdvancedCommerceRequest.py b/appstoreserverlibrary/models/AdvancedCommerceRequest.py new file mode 100644 index 00000000..fbcf66b7 --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceRequest.py @@ -0,0 +1,18 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from abc import ABC + +from attr import define +import attr + +from .AdvancedCommerceRequestInfo import AdvancedCommerceRequestInfo +from .LibraryUtility import AttrsRawValueAware + +@define +class AdvancedCommerceRequest(AttrsRawValueAware, ABC): + requestInfo: AdvancedCommerceRequestInfo = attr.ib() + """ + The metadata to include in server requests. + + https://developer.apple.com/documentation/advancedcommerceapi/requestinfo + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/AdvancedCommerceRequestInfo.py b/appstoreserverlibrary/models/AdvancedCommerceRequestInfo.py new file mode 100644 index 00000000..98386e18 --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceRequestInfo.py @@ -0,0 +1,32 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from typing import Optional +from uuid import UUID + +from attr import define +import attr + +@define +class AdvancedCommerceRequestInfo: + """ + The metadata to include in server requests. + + https://developer.apple.com/documentation/advancedcommerceapi/requestinfo + """ + + requestReferenceId: UUID = attr.ib() + """ + A UUID that you provide to uniquely identify each request. If the request times out, you can use the same requestReferenceId value to retry the request. Otherwise, provide a unique value. + """ + + appAccountToken: Optional[UUID] = attr.ib(default=None) + """ + A UUID that represents an app account token, to associate with the transaction in the request. + """ + + consistencyToken: Optional[str] = attr.ib(default=None) + """ + The value of the advancedCommerceConsistencyToken that you receive in the JWSRenewalInfo renewal information for a subscription. Don’t generate this value. + + https://developer.apple.com/documentation/AppStoreServerAPI/advancedCommerceConsistencyToken + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/AdvancedCommerceRequestRefundItem.py b/appstoreserverlibrary/models/AdvancedCommerceRequestRefundItem.py new file mode 100644 index 00000000..07a62205 --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceRequestRefundItem.py @@ -0,0 +1,49 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from typing import Optional + +from attr import define +import attr + +from .AbstractAdvancedCommerceBaseItem import AbstractAdvancedCommerceBaseItem +from .AdvancedCommerceRefundReason import AdvancedCommerceRefundReason +from .AdvancedCommerceRefundType import AdvancedCommerceRefundType + +@define +class AdvancedCommerceRequestRefundItem(AbstractAdvancedCommerceBaseItem): + """ + Information about the refund request for an item, such as its SKU, the refund amount, reason, and type. + + https://developer.apple.com/documentation/advancedcommerceapi/requestrefunditem + """ + + revoke: bool = attr.ib() + + refundReason: AdvancedCommerceRefundReason = AdvancedCommerceRefundReason.create_main_attr('rawRefundReason', raw_required=True) + """ + The reason for the refund request. + + https://developer.apple.com/documentation/advancedcommerceapi/refundreason + """ + + rawRefundReason: str = AdvancedCommerceRefundReason.create_raw_attr('refundReason', required=True) + """ + See refundReason + """ + + refundType: AdvancedCommerceRefundType = AdvancedCommerceRefundType.create_main_attr('rawRefundType', raw_required=True) + """ + The type of refund requested. + """ + + rawRefundType: str = AdvancedCommerceRefundType.create_raw_attr('refundType', required=True) + """ + See refundType + """ + + refundAmount: Optional[int] = attr.ib(default=None) + """ + The refund amount you're requesting for the SKU, in milliunits of the currency. + + https://developer.apple.com/documentation/advancedcommerceapi/refundamount + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/AdvancedCommerceRequestRefundRequest.py b/appstoreserverlibrary/models/AdvancedCommerceRequestRefundRequest.py new file mode 100644 index 00000000..d2fb55cc --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceRequestRefundRequest.py @@ -0,0 +1,40 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from typing import Optional, List + +from attr import define +import attr + +from .AdvancedCommerceRequest import AdvancedCommerceRequest +from .AdvancedCommerceRequestRefundItem import AdvancedCommerceRequestRefundItem +from .HelperValidationUtils import HelperValidationUtils + +@define +class AdvancedCommerceRequestRefundRequest(AdvancedCommerceRequest): + """ + The request body for requesting a refund for a transaction. + + https://developer.apple.com/documentation/advancedcommerceapi/requestrefundrequest + """ + + items: List[AdvancedCommerceRequestRefundItem] = attr.ib(validator=HelperValidationUtils.items_validator) + """ + https://developer.apple.com/documentation/advancedcommerceapi/requestrefunditem + """ + + refundRiskingPreference: bool = attr.ib() + """ + https://developer.apple.com/documentation/advancedcommerceapi/refundriskingpreference + """ + + currency: Optional[str] = attr.ib(default=None) + """ + The currency of the transaction. + + https://developer.apple.com/documentation/advancedcommerceapi/currency + """ + + storefront: Optional[str] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/advancedcommerceapi/storefront + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/AdvancedCommerceRequestRefundResponse.py b/appstoreserverlibrary/models/AdvancedCommerceRequestRefundResponse.py new file mode 100644 index 00000000..03e9a1ff --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceRequestRefundResponse.py @@ -0,0 +1,13 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from .AbstractAdvancedCommerceResponse import AbstractAdvancedCommerceResponse + +class AdvancedCommerceRequestRefundResponse(AbstractAdvancedCommerceResponse): + """ + The response body for a transaction refund request. + + https://developer.apple.com/documentation/advancedcommerceapi/requestrefundresponse + """ + + def __init__(self, signedTransactionInfo: str): + super().__init__(signedRenewalInfo=None, signedTransactionInfo=signedTransactionInfo) \ No newline at end of file diff --git a/appstoreserverlibrary/models/AdvancedCommerceSubscriptionCancelRequest.py b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionCancelRequest.py new file mode 100644 index 00000000..db6e8317 --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionCancelRequest.py @@ -0,0 +1,21 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from typing import Optional + +from attr import define +import attr + +from .AdvancedCommerceRequest import AdvancedCommerceRequest + +@define +class AdvancedCommerceSubscriptionCancelRequest(AdvancedCommerceRequest): + """ + The request body for turning off automatic renewal of a subscription. + + https://developer.apple.com/documentation/advancedcommerceapi/subscriptioncancelrequest + """ + + storefront: Optional[str] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/advancedcommerceapi/storefront + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/AdvancedCommerceSubscriptionCancelResponse.py b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionCancelResponse.py new file mode 100644 index 00000000..b0f7a246 --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionCancelResponse.py @@ -0,0 +1,14 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from attr import define + +from .AbstractAdvancedCommerceResponse import AbstractAdvancedCommerceResponse + +@define +class AdvancedCommerceSubscriptionCancelResponse(AbstractAdvancedCommerceResponse): + """ + The response body for a successful subscription cancellation. + + https://developer.apple.com/documentation/advancedcommerceapi/subscriptioncancelresponse + """ + pass \ No newline at end of file diff --git a/appstoreserverlibrary/models/AdvancedCommerceSubscriptionChangeMetadataDescriptors.py b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionChangeMetadataDescriptors.py new file mode 100644 index 00000000..99e0c782 --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionChangeMetadataDescriptors.py @@ -0,0 +1,48 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. +from typing import Optional + +from attr import define +import attr + +from .AdvancedCommerceEffective import AdvancedCommerceEffective +from .HelperValidationUtils import HelperValidationUtils + +@define +class AdvancedCommerceSubscriptionChangeMetadataDescriptors(): + """ + The subscription metadata to change, specifically the description and display name. + + https://developer.apple.com/documentation/advancedcommerceapi/subscriptionchangemetadatadescriptors + """ + + effective: AdvancedCommerceEffective = AdvancedCommerceEffective.create_main_attr('rawEffective', raw_required=True) + """ + The string that determines when the metadata change goes into effect. + + https://developer.apple.com/documentation/advancedcommerceapi/effective + """ + + rawEffective: str = AdvancedCommerceEffective.create_raw_attr('effective', required=True) + """ + See effective + """ + + description: Optional[str] = attr.ib( + default=None, + validator=attr.validators.optional(HelperValidationUtils.description_validator) + ) + """ + The new description for the subscription. + + https://developer.apple.com/documentation/advancedcommerceapi/description + """ + + displayName: Optional[str] = attr.ib( + default=None, + validator=attr.validators.optional(HelperValidationUtils.display_name_validator) + ) + """ + The new display name for the subscription. + + https://developer.apple.com/documentation/advancedcommerceapi/displayname + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/AdvancedCommerceSubscriptionChangeMetadataItem.py b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionChangeMetadataItem.py new file mode 100644 index 00000000..5d7e83d6 --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionChangeMetadataItem.py @@ -0,0 +1,54 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. +from typing import Optional + +from attr import define +import attr + +from .AdvancedCommerceEffective import AdvancedCommerceEffective +from .HelperValidationUtils import HelperValidationUtils + +@define +class AdvancedCommerceSubscriptionChangeMetadataItem(): + """ + The metadata to change for an item, specifically its SKU, description, and display name. + + https://developer.apple.com/documentation/advancedcommerceapi/subscriptionchangemetadataitem + """ + + currentSKU: str = attr.ib(validator=HelperValidationUtils.sku_validator) + """ + The original SKU of the item. + """ + + effective: AdvancedCommerceEffective = AdvancedCommerceEffective.create_main_attr('rawEffective', raw_required=True) + """ + The string that determines when the metadata change goes into effect. + + https://developer.apple.com/documentation/advancedcommerceapi/effective + """ + + rawEffective: str = AdvancedCommerceEffective.create_raw_attr('effective', required=True) + """ + See effective + """ + + description: Optional[str] = attr.ib(default=None, validator=attr.validators.optional(HelperValidationUtils.description_validator)) + """ + The new description for the item. + + https://developer.apple.com/documentation/advancedcommerceapi/description + """ + + displayName: Optional[str] = attr.ib(default=None, validator=attr.validators.optional(HelperValidationUtils.display_name_validator)) + """ + The new display name for the item. + + https://developer.apple.com/documentation/advancedcommerceapi/displayname + """ + + SKU: Optional[str] = attr.ib(default=None, validator=attr.validators.optional(HelperValidationUtils.sku_validator)) + """ + The new SKU of the item. + + https://developer.apple.com/documentation/advancedcommerceapi/sku + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/AdvancedCommerceSubscriptionChangeMetadataRequest.py b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionChangeMetadataRequest.py new file mode 100644 index 00000000..69fb57fc --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionChangeMetadataRequest.py @@ -0,0 +1,39 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from typing import Optional, List + +from attr import define +import attr + +from .AdvancedCommerceRequest import AdvancedCommerceRequest +from .AdvancedCommerceSubscriptionChangeMetadataDescriptors import AdvancedCommerceSubscriptionChangeMetadataDescriptors +from .AdvancedCommerceSubscriptionChangeMetadataItem import AdvancedCommerceSubscriptionChangeMetadataItem +from .HelperValidationUtils import HelperValidationUtils + +@define +class AdvancedCommerceSubscriptionChangeMetadataRequest(AdvancedCommerceRequest): + """ + The request body you provide to change the metadata of a subscription. + + https://developer.apple.com/documentation/advancedcommerceapi/subscriptionchangemetadatarequest + """ + + descriptors: Optional[AdvancedCommerceSubscriptionChangeMetadataDescriptors] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/advancedcommerceapi/subscriptionchangemetadatadescriptors + """ + + items: Optional[List[AdvancedCommerceSubscriptionChangeMetadataItem]] = attr.ib(default=None, validator=attr.validators.optional(HelperValidationUtils.items_validator)) + """ + https://developer.apple.com/documentation/advancedcommerceapi/subscriptionchangemetadataitem + """ + + storefront: Optional[str] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/advancedcommerceapi/storefront + """ + + taxCode: Optional[str] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/advancedcommerceapi/taxcode + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/AdvancedCommerceSubscriptionChangeMetadataResponse.py b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionChangeMetadataResponse.py new file mode 100644 index 00000000..b7c157d6 --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionChangeMetadataResponse.py @@ -0,0 +1,13 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from .AbstractAdvancedCommerceResponse import AbstractAdvancedCommerceResponse + +class AdvancedCommerceSubscriptionChangeMetadataResponse(AbstractAdvancedCommerceResponse): + """ + The response body for a successful subscription metadata change. + + https://developer.apple.com/documentation/advancedcommerceapi/subscriptionchangemetadataresponse + """ + + def __init__(self, signedRenewalInfo: str, signedTransactionInfo: str): + super().__init__(signedRenewalInfo=signedRenewalInfo, signedTransactionInfo=signedTransactionInfo) \ No newline at end of file diff --git a/appstoreserverlibrary/models/AdvancedCommerceSubscriptionCreateItem.py b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionCreateItem.py new file mode 100644 index 00000000..aab39cd5 --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionCreateItem.py @@ -0,0 +1,26 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from typing import Optional +import attr +from attr import define +from .AbstractAdvancedCommerceItem import AbstractAdvancedCommerceItem +from .AdvancedCommerceOffer import AdvancedCommerceOffer + + +@define +class AdvancedCommerceSubscriptionCreateItem(AbstractAdvancedCommerceItem): + """ + The data that describes a subscription item. + + https://developer.apple.com/documentation/advancedcommerceapi/subscriptioncreateitem + """ + + price: int = attr.ib() + """ + https://developer.apple.com/documentation/advancedcommerceapi/price + """ + + offer: Optional[AdvancedCommerceOffer] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/advancedcommerceapi/offer + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/AdvancedCommerceSubscriptionCreateRequest.py b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionCreateRequest.py new file mode 100644 index 00000000..85bb8d6a --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionCreateRequest.py @@ -0,0 +1,62 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from typing import Optional, List +import attr +from attr import define +from .AbstractAdvancedCommerceInAppRequest import AbstractAdvancedCommerceInAppRequest +from .AdvancedCommerceDescriptors import AdvancedCommerceDescriptors +from .AdvancedCommerceSubscriptionCreateItem import AdvancedCommerceSubscriptionCreateItem +from .AdvancedCommercePeriod import AdvancedCommercePeriod + + +@define +class AdvancedCommerceSubscriptionCreateRequest(AbstractAdvancedCommerceInAppRequest): + """ + The request data your app provides when a customer purchases an auto-renewable subscription. + + https://developer.apple.com/documentation/advancedcommerceapi/subscriptioncreaterequest + """ + + operation: str = attr.ib(init=False, default="CREATE_SUBSCRIPTION", on_setattr=attr.setters.frozen) + + version: str = attr.ib(init=False, default="1", on_setattr=attr.setters.frozen) + + currency: str = attr.ib() + """ + https://developer.apple.com/documentation/advancedcommerceapi/currency + """ + + descriptors: AdvancedCommerceDescriptors = attr.ib() + """ + https://developer.apple.com/documentation/advancedcommerceapi/descriptors + """ + + items: List[AdvancedCommerceSubscriptionCreateItem] = attr.ib() + """ + https://developer.apple.com/documentation/advancedcommerceapi/subscriptioncreateitem + """ + + taxCode: str = attr.ib() + """ + https://developer.apple.com/documentation/advancedcommerceapi/taxCode + """ + + period: AdvancedCommercePeriod = AdvancedCommercePeriod.create_main_attr('rawPeriod', raw_required=True) + """ + https://developer.apple.com/documentation/advancedcommerceapi/period + """ + + rawPeriod: str = AdvancedCommercePeriod.create_raw_attr('period', required=True) + """ + See period + """ + + previousTransactionId: Optional[str] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/advancedcommerceapi/transactionid + """ + + storefront: Optional[str] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/advancedcommerceapi/storefront + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/AdvancedCommerceSubscriptionMigrateDescriptors.py b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionMigrateDescriptors.py new file mode 100644 index 00000000..1402cc27 --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionMigrateDescriptors.py @@ -0,0 +1,13 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from attr import define +from .AdvancedCommerceDescriptors import AdvancedCommerceDescriptors + + +@define +class AdvancedCommerceSubscriptionMigrateDescriptors(AdvancedCommerceDescriptors): + """ + The description and display name of the subscription to migrate to that you manage. + + https://developer.apple.com/documentation/advancedcommerceapi/subscriptionmigratedescriptors + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/AdvancedCommerceSubscriptionMigrateItem.py b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionMigrateItem.py new file mode 100644 index 00000000..49838a41 --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionMigrateItem.py @@ -0,0 +1,13 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from attr import define +from .AbstractAdvancedCommerceItem import AbstractAdvancedCommerceItem + + +@define +class AdvancedCommerceSubscriptionMigrateItem(AbstractAdvancedCommerceItem): + """ + The SKU, description, and display name to use for a migrated subscription item. + + https://developer.apple.com/documentation/advancedcommerceapi/subscriptionmigrateitem + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/AdvancedCommerceSubscriptionMigrateRenewalItem.py b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionMigrateRenewalItem.py new file mode 100644 index 00000000..0c4af397 --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionMigrateRenewalItem.py @@ -0,0 +1,13 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from attr import define +from .AbstractAdvancedCommerceItem import AbstractAdvancedCommerceItem + + +@define +class AdvancedCommerceSubscriptionMigrateRenewalItem(AbstractAdvancedCommerceItem): + """ + The item information that replaces a migrated subscription item when the subscription renews. + + https://developer.apple.com/documentation/advancedcommerceapi/subscriptionmigraterenewalitem + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/AdvancedCommerceSubscriptionMigrateRequest.py b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionMigrateRequest.py new file mode 100644 index 00000000..da54738c --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionMigrateRequest.py @@ -0,0 +1,54 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from typing import Optional, List +import attr +from attr import define +from .AdvancedCommerceRequest import AdvancedCommerceRequest +from .HelperValidationUtils import HelperValidationUtils +from .AdvancedCommerceSubscriptionMigrateDescriptors import AdvancedCommerceSubscriptionMigrateDescriptors +from .AdvancedCommerceSubscriptionMigrateItem import AdvancedCommerceSubscriptionMigrateItem +from .AdvancedCommerceSubscriptionMigrateRenewalItem import AdvancedCommerceSubscriptionMigrateRenewalItem + +@define +class AdvancedCommerceSubscriptionMigrateRequest(AdvancedCommerceRequest): + """ + The subscription details you provide to migrate a subscription from In-App Purchase to the Advanced Commerce API, such as descriptors, items, storefront, and more. + + https://developer.apple.com/documentation/advancedcommerceapi/subscriptionmigraterequest + """ + + descriptors: AdvancedCommerceSubscriptionMigrateDescriptors = attr.ib() + """ + https://developer.apple.com/documentation/advancedcommerceapi/subscriptionmigratedescriptors + """ + + items: List[AdvancedCommerceSubscriptionMigrateItem] = attr.ib(validator=HelperValidationUtils.items_validator) + """ + An array of one or more SKUs, along with descriptions and display names, that are included in the subscription. + + https://developer.apple.com/documentation/advancedcommerceapi/subscriptionmigrateitem + """ + + targetProductId: str = attr.ib() + """ + Your generic product ID for an auto-renewable subscription. + + https://developer.apple.com/documentation/advancedcommerceapi/targetproductid + """ + + taxCode: str = attr.ib() + """ + https://developer.apple.com/documentation/advancedcommerceapi/taxcode + """ + + renewalItems: Optional[List[AdvancedCommerceSubscriptionMigrateRenewalItem]] = attr.ib(default=None, validator=attr.validators.optional(HelperValidationUtils.items_validator)) + """ + An optional array of subscription items that represents the items that renew at the next renewal period, if they differ from items. + + https://developer.apple.com/documentation/advancedcommerceapi/subscriptionmigraterenewalitem + """ + + storefront: Optional[str] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/advancedcommerceapi/storefront + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/AdvancedCommerceSubscriptionMigrateResponse.py b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionMigrateResponse.py new file mode 100644 index 00000000..152b0d22 --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionMigrateResponse.py @@ -0,0 +1,15 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from attr import define +from .AbstractAdvancedCommerceResponse import AbstractAdvancedCommerceResponse + + +@define +class AdvancedCommerceSubscriptionMigrateResponse(AbstractAdvancedCommerceResponse): + """ + A response that contains signed renewal and transaction information after a subscription successfully migrates to the Advanced Commerce API. + + https://developer.apple.com/documentation/advancedcommerceapi/subscriptionmigrateresponse + """ + def __init__(self, signedRenewalInfo: str, signedTransactionInfo: str): + super().__init__(signedRenewalInfo=signedRenewalInfo, signedTransactionInfo=signedTransactionInfo) \ No newline at end of file diff --git a/appstoreserverlibrary/models/AdvancedCommerceSubscriptionModifyAddItem.py b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionModifyAddItem.py new file mode 100644 index 00000000..a1470659 --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionModifyAddItem.py @@ -0,0 +1,33 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from typing import Optional +import attr +from attr import define +from .AbstractAdvancedCommerceItem import AbstractAdvancedCommerceItem +from .AdvancedCommerceOffer import AdvancedCommerceOffer + + +@define +class AdvancedCommerceSubscriptionModifyAddItem(AbstractAdvancedCommerceItem): + """ + The data your app provides to add items when it makes changes to an auto-renewable subscription. + + https://developer.apple.com/documentation/advancedcommerceapi/subscriptionmodifyadditem + """ + + price: int = attr.ib() + """ + https://developer.apple.com/documentation/advancedcommerceapi/price + """ + + offer: Optional[AdvancedCommerceOffer] = attr.ib(default=None) + """ + A discount offer for an auto-renewable subscription. + + https://developer.apple.com/documentation/advancedcommerceapi/offer + """ + + proratedPrice: Optional[int] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/advancedcommerceapi/proratedprice + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/AdvancedCommerceSubscriptionModifyChangeItem.py b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionModifyChangeItem.py new file mode 100644 index 00000000..532ce30b --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionModifyChangeItem.py @@ -0,0 +1,56 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from typing import Optional +import attr +from attr import define +from .AbstractAdvancedCommerceItem import AbstractAdvancedCommerceItem +from .AdvancedCommerceEffective import AdvancedCommerceEffective +from .AdvancedCommerceOffer import AdvancedCommerceOffer +from .AdvancedCommerceReason import AdvancedCommerceReason +from .HelperValidationUtils import HelperValidationUtils + + +@define +class AdvancedCommerceSubscriptionModifyChangeItem(AbstractAdvancedCommerceItem): + """ + The data your app provides to change an item of an auto-renewable subscription. + + https://developer.apple.com/documentation/advancedcommerceapi/subscriptionmodifychangeitem + """ + + currentSKU: str = attr.ib(validator=HelperValidationUtils.sku_validator) + """ + https://developer.apple.com/documentation/advancedcommerceapi/sku + """ + + price: int = attr.ib() + """ + https://developer.apple.com/documentation/advancedcommerceapi/price + """ + + reason: AdvancedCommerceReason = AdvancedCommerceReason.create_main_attr('rawReason', raw_required=True) + + rawReason: str = AdvancedCommerceReason.create_raw_attr('reason', required=True) + """ + See reason + """ + + effective: AdvancedCommerceEffective = AdvancedCommerceEffective.create_main_attr('rawEffective', raw_required=True) + """ + https://developer.apple.com/documentation/advancedcommerceapi/effective + """ + + rawEffective: str = AdvancedCommerceEffective.create_raw_attr('effective', required=True) + """ + See effective + """ + + offer: Optional[AdvancedCommerceOffer] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/advancedcommerceapi/offer + """ + + proratedPrice: Optional[int] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/advancedcommerceapi/proratedprice + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/AdvancedCommerceSubscriptionModifyDescriptors.py b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionModifyDescriptors.py new file mode 100644 index 00000000..b46edde0 --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionModifyDescriptors.py @@ -0,0 +1,36 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. +from typing import Optional + +import attr +from attr import define +from .AdvancedCommerceEffective import AdvancedCommerceEffective +from .HelperValidationUtils import HelperValidationUtils + +@define +class AdvancedCommerceSubscriptionModifyDescriptors(): + """ + The data your app provides to change the description and display name of an auto-renewable subscription. + + https://developer.apple.com/documentation/advancedcommerceapi/subscriptionmodifydescriptors + """ + + effective: AdvancedCommerceEffective = AdvancedCommerceEffective.create_main_attr('rawEffective', raw_required=True) + """ + https://developer.apple.com/documentation/advancedcommerceapi/effective + """ + + rawEffective: str = AdvancedCommerceEffective.create_raw_attr('effective', required=True) + """ + See effective + """ + + description: Optional[str] = attr.ib(default=None, validator=attr.validators.optional(HelperValidationUtils.description_validator)) + """ + https://developer.apple.com/documentation/advancedcommerceapi/description + """ + + displayName: Optional[str] = attr.ib(default=None, validator=attr.validators.optional(HelperValidationUtils.display_name_validator) + ) + """ + https://developer.apple.com/documentation/advancedcommerceapi/displayname + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/AdvancedCommerceSubscriptionModifyInAppRequest.py b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionModifyInAppRequest.py new file mode 100644 index 00000000..e1ac2611 --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionModifyInAppRequest.py @@ -0,0 +1,75 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from typing import Optional, List +import attr +from attr import define +from .AbstractAdvancedCommerceInAppRequest import AbstractAdvancedCommerceInAppRequest +from .HelperValidationUtils import HelperValidationUtils +from .AdvancedCommerceSubscriptionModifyAddItem import AdvancedCommerceSubscriptionModifyAddItem +from .AdvancedCommerceSubscriptionModifyChangeItem import AdvancedCommerceSubscriptionModifyChangeItem +from .AdvancedCommerceSubscriptionModifyDescriptors import AdvancedCommerceSubscriptionModifyDescriptors +from .AdvancedCommerceSubscriptionModifyPeriodChange import AdvancedCommerceSubscriptionModifyPeriodChange +from .AdvancedCommerceSubscriptionModifyRemoveItem import AdvancedCommerceSubscriptionModifyRemoveItem + + +@define +class AdvancedCommerceSubscriptionModifyInAppRequest(AbstractAdvancedCommerceInAppRequest): + """ + The request data your app provides to make changes to an auto-renewable subscription. + + https://developer.apple.com/documentation/advancedcommerceapi/subscriptionmodifyinapprequest + """ + + operation: str = attr.ib(default="MODIFY_SUBSCRIPTION", init=False, on_setattr=attr.setters.frozen) + + version: str = attr.ib(default="1", init=False, on_setattr=attr.setters.frozen) + + transactionId: str = attr.ib() + """ + https://developer.apple.com/documentation/advancedcommerceapi/transactionid + """ + + retainBillingCycle: bool = attr.ib() + """ + https://developer.apple.com/documentation/advancedcommerceapi/retainbillingcycle + """ + + addItems: Optional[List[AdvancedCommerceSubscriptionModifyAddItem]] = attr.ib(default=None, validator=attr.validators.optional(HelperValidationUtils.items_validator)) + """ + https://developer.apple.com/documentation/advancedcommerceapi/subscriptionmodifyadditem + """ + + changeItems: Optional[List[AdvancedCommerceSubscriptionModifyChangeItem]] = attr.ib(default=None, validator=attr.validators.optional(HelperValidationUtils.items_validator)) + """ + https://developer.apple.com/documentation/advancedcommerceapi/subscriptionmodifychangeitem + """ + + currency: Optional[str] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/advancedcommerceapi/currency + """ + + descriptors: Optional[AdvancedCommerceSubscriptionModifyDescriptors] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/advancedcommerceapi/subscriptionmodifydescriptors + """ + + periodChange: Optional[AdvancedCommerceSubscriptionModifyPeriodChange] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/advancedcommerceapi/subscriptionmodifyperiodchange + """ + + removeItems: Optional[List[AdvancedCommerceSubscriptionModifyRemoveItem]] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/advancedcommerceapi/subscriptionmodifyremoveitem + """ + + storefront: Optional[str] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/advancedcommerceapi/storefront + """ + + taxCode: Optional[str] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/advancedcommerceapi/taxcode + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/AdvancedCommerceSubscriptionModifyPeriodChange.py b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionModifyPeriodChange.py new file mode 100644 index 00000000..a2f33177 --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionModifyPeriodChange.py @@ -0,0 +1,36 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +import attr +from attr import define +from .AdvancedCommerceEffective import AdvancedCommerceEffective +from .AdvancedCommercePeriod import AdvancedCommercePeriod +from .LibraryUtility import AttrsRawValueAware + + +@define +class AdvancedCommerceSubscriptionModifyPeriodChange(AttrsRawValueAware): + """ + The data your app provides to change the period of an auto-renewable subscription. + + https://developer.apple.com/documentation/advancedcommerceapi/subscriptionmodifyperiodchange + """ + + effective: AdvancedCommerceEffective = AdvancedCommerceEffective.create_main_attr('rawEffective', raw_required=True) + """ + https://developer.apple.com/documentation/advancedcommerceapi/effective + """ + + rawEffective: str = AdvancedCommerceEffective.create_raw_attr('effective', required=True) + """ + See effective + """ + + period: AdvancedCommercePeriod = AdvancedCommercePeriod.create_main_attr('rawPeriod', raw_required=True) + """ + https://developer.apple.com/documentation/advancedcommerceapi/period + """ + + rawPeriod: str = AdvancedCommercePeriod.create_raw_attr('period', required=True) + """ + See period + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/AdvancedCommerceSubscriptionModifyRemoveItem.py b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionModifyRemoveItem.py new file mode 100644 index 00000000..f978e7d3 --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionModifyRemoveItem.py @@ -0,0 +1,13 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from attr import define +from .AbstractAdvancedCommerceBaseItem import AbstractAdvancedCommerceBaseItem + + +@define +class AdvancedCommerceSubscriptionModifyRemoveItem(AbstractAdvancedCommerceBaseItem): + """ + The data your app provides to remove an item from an auto-renewable subscription. + + https://developer.apple.com/documentation/advancedcommerceapi/subscriptionmodifyremoveitem + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/AdvancedCommerceSubscriptionPriceChangeItem.py b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionPriceChangeItem.py new file mode 100644 index 00000000..77889374 --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionPriceChangeItem.py @@ -0,0 +1,25 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +import attr +from attr import define +from typing import List, Optional +from .AbstractAdvancedCommerceBaseItem import AbstractAdvancedCommerceBaseItem +from .HelperValidationUtils import HelperValidationUtils + +@define +class AdvancedCommerceSubscriptionPriceChangeItem(AbstractAdvancedCommerceBaseItem): + """ + The data your app provides to change a subscription price. + + https://developer.apple.com/documentation/advancedcommerceapi/subscriptionpricechangeitem + """ + + price: int = attr.ib() + """ + https://developer.apple.com/documentation/advancedcommerceapi/price + """ + + dependentSKUs: Optional[List[str]] = attr.ib(default=None, validator=attr.validators.optional(HelperValidationUtils.dependent_skus_validator)) + """ + https://developer.apple.com/documentation/advancedcommerceapi/dependentsku + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/AdvancedCommerceSubscriptionPriceChangeRequest.py b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionPriceChangeRequest.py new file mode 100644 index 00000000..13612b9a --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionPriceChangeRequest.py @@ -0,0 +1,36 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from typing import Optional, List +import attr +from attr import define +from .AdvancedCommerceRequest import AdvancedCommerceRequest +from .AdvancedCommerceSubscriptionPriceChangeItem import AdvancedCommerceSubscriptionPriceChangeItem + +@define +class AdvancedCommerceSubscriptionPriceChangeRequest(AdvancedCommerceRequest): + """ + The request body you use to change the price of an auto-renewable subscription. + + https://developer.apple.com/documentation/advancedcommerceapi/subscriptionpricechangerequest + """ + + items: List[AdvancedCommerceSubscriptionPriceChangeItem] = attr.ib() + """ + An array that contains one or more SKUs and the changed price for each SKU. + + https://developer.apple.com/documentation/advancedcommerceapi/subscriptionpricechangeitem + """ + + currency: Optional[str] = attr.ib(default=None) + """ + The currency of the prices. + + https://developer.apple.com/documentation/advancedcommerceapi/currency + """ + + storefront: Optional[str] = attr.ib(default=None) + """ + The App Store storefront of the subscription. + + https://developer.apple.com/documentation/advancedcommerceapi/storefront + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/AdvancedCommerceSubscriptionPriceChangeResponse.py b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionPriceChangeResponse.py new file mode 100644 index 00000000..264b1d67 --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionPriceChangeResponse.py @@ -0,0 +1,15 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from attr import define +from .AbstractAdvancedCommerceResponse import AbstractAdvancedCommerceResponse + + +@define +class AdvancedCommerceSubscriptionPriceChangeResponse(AbstractAdvancedCommerceResponse): + """ + A response that contains signed JWS renewal and JWS transaction information after a subscription price change request. + + https://developer.apple.com/documentation/advancedcommerceapi/subscriptionpricechangeresponse + """ + def __init__(self, signedRenewalInfo: str, signedTransactionInfo: str): + super().__init__(signedRenewalInfo=signedRenewalInfo, signedTransactionInfo=signedTransactionInfo) \ No newline at end of file diff --git a/appstoreserverlibrary/models/AdvancedCommerceSubscriptionReactivateInAppRequest.py b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionReactivateInAppRequest.py new file mode 100644 index 00000000..ee7cdb78 --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionReactivateInAppRequest.py @@ -0,0 +1,33 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from __future__ import annotations +from typing import List, Optional +import attr +from appstoreserverlibrary.models.AbstractAdvancedCommerceInAppRequest import AbstractAdvancedCommerceInAppRequest +from appstoreserverlibrary.models.AdvancedCommerceSubscriptionReactivateItem import AdvancedCommerceSubscriptionReactivateItem + +@attr.define +class AdvancedCommerceSubscriptionReactivateInAppRequest(AbstractAdvancedCommerceInAppRequest): + """ + The request your app provides to reactivate a subscription that has automatic renewal turned off. + + https://developer.apple.com/documentation/advancedcommerceapi/subscriptionreactivateinapprequest + """ + operation: str = attr.ib(init=False, default="REACTIVATE_SUBSCRIPTION", on_setattr=attr.setters.frozen) + + version: str = attr.ib(init=False, default="1", on_setattr=attr.setters.frozen) + + transactionId: str = attr.ib() + """ + https://developer.apple.com/documentation/appstoreserverapi/transactionid + """ + + items: Optional[List[AdvancedCommerceSubscriptionReactivateItem]] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/advancedcommerceapi/subscriptionreactivateitem + """ + + storefront: Optional[str] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/advancedcommerceapi/storefront + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/AdvancedCommerceSubscriptionReactivateItem.py b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionReactivateItem.py new file mode 100644 index 00000000..7561a994 --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionReactivateItem.py @@ -0,0 +1,13 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from attr import define +from appstoreserverlibrary.models.AbstractAdvancedCommerceBaseItem import AbstractAdvancedCommerceBaseItem + + +@define +class AdvancedCommerceSubscriptionReactivateItem(AbstractAdvancedCommerceBaseItem): + """ + An item in a subscription to reactive. + + https://developer.apple.com/documentation/advancedcommerceapi/subscriptionreactivateitem + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/AdvancedCommerceSubscriptionRevokeRequest.py b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionRevokeRequest.py new file mode 100644 index 00000000..f8d32f70 --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionRevokeRequest.py @@ -0,0 +1,44 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from __future__ import annotations +from typing import Optional +import attr +from appstoreserverlibrary.models.AdvancedCommerceRequest import AdvancedCommerceRequest +from appstoreserverlibrary.models.AdvancedCommerceRefundReason import AdvancedCommerceRefundReason +from appstoreserverlibrary.models.AdvancedCommerceRefundType import AdvancedCommerceRefundType + + +@attr.define +class AdvancedCommerceSubscriptionRevokeRequest(AdvancedCommerceRequest): + """ + The request body you provide to terminate a subscription and all its items immediately. + + https://developer.apple.com/documentation/advancedcommerceapi/subscriptionrevokerequest + """ + + refundRiskingPreference: bool = attr.ib() + """ + https://developer.apple.com/documentation/advancedcommerceapi/refundriskingpreference + """ + + refundType: AdvancedCommerceRefundType = AdvancedCommerceRefundType.create_main_attr('rawRefundType', raw_required=True) + + rawRefundType: str = AdvancedCommerceRefundType.create_raw_attr('refundType', required=True) + """ + See refundType + """ + + refundReason: AdvancedCommerceRefundReason = AdvancedCommerceRefundReason.create_main_attr('rawRefundReason', raw_required=True) + """ + https://developer.apple.com/documentation/advancedcommerceapi/refundreason + """ + + rawRefundReason: str = AdvancedCommerceRefundReason.create_raw_attr('refundReason', required=True) + """ + See refundReason + """ + + storefront: Optional[str] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/advancedcommerceapi/storefront + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/AdvancedCommerceSubscriptionRevokeResponse.py b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionRevokeResponse.py new file mode 100644 index 00000000..e7066078 --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionRevokeResponse.py @@ -0,0 +1,15 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from attr import define +from appstoreserverlibrary.models.AbstractAdvancedCommerceResponse import AbstractAdvancedCommerceResponse + + +@define +class AdvancedCommerceSubscriptionRevokeResponse(AbstractAdvancedCommerceResponse): + """ + The response body for a successful revoke-subscription request. + + https://developer.apple.com/documentation/advancedcommerceapi/subscriptionrevokeresponse + """ + def __init__(self, signedRenewalInfo: str, signedTransactionInfo: str): + super().__init__(signedRenewalInfo=signedRenewalInfo, signedTransactionInfo=signedTransactionInfo) \ No newline at end of file diff --git a/appstoreserverlibrary/models/AdvancedCommerceTransactionInfo.py b/appstoreserverlibrary/models/AdvancedCommerceTransactionInfo.py new file mode 100644 index 00000000..5f628d4b --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceTransactionInfo.py @@ -0,0 +1,62 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from typing import List, Optional + +from attr import define +import attr + +from .AdvancedCommerceDescriptors import AdvancedCommerceDescriptors +from .AdvancedCommercePeriod import AdvancedCommercePeriod +from .AdvancedCommerceTransactionItem import AdvancedCommerceTransactionItem +from .LibraryUtility import AttrsRawValueAware + +@define +class AdvancedCommerceTransactionInfo(AttrsRawValueAware): + """ + https://developer.apple.com/documentation/appstoreserverapi/advancedcommercetransactioninfo + """ + + descriptors: Optional[AdvancedCommerceDescriptors] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/appstoreserverapi/advancedcommercedescriptors + """ + + estimatedTax: Optional[int] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/appstoreserverapi/advancedcommerceestimatedtax + """ + + items: Optional[List[AdvancedCommerceTransactionItem]] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/appstoreserverapi/advancedcommercetransactionitem + """ + + period: Optional[AdvancedCommercePeriod] = AdvancedCommercePeriod.create_main_attr('rawPeriod') + """ + https://developer.apple.com/documentation/appstoreserverapi/advancedcommerceperiod + """ + + rawPeriod: Optional[str] = AdvancedCommercePeriod.create_raw_attr('period') + """ + See period + """ + + requestReferenceId: Optional[str] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/appstoreserverapi/advancedcommercerequestreferenceid + """ + + taxCode: Optional[str] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/appstoreserverapi/advancedcommercetaxcode + """ + + taxExclusivePrice: Optional[int] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/appstoreserverapi/advancedcommercetaxexclusiveprice + """ + + taxRate: Optional[str] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/appstoreserverapi/advancedcommercetaxrate + """ diff --git a/appstoreserverlibrary/models/AdvancedCommerceTransactionItem.py b/appstoreserverlibrary/models/AdvancedCommerceTransactionItem.py new file mode 100644 index 00000000..97a64b23 --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceTransactionItem.py @@ -0,0 +1,50 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from typing import List, Optional + +from attr import define +import attr + +from .AdvancedCommerceOffer import AdvancedCommerceOffer +from .AdvancedCommerceRefund import AdvancedCommerceRefund + +@define +class AdvancedCommerceTransactionItem: + """ + https://developer.apple.com/documentation/appstoreserverapi/advancedcommercetransactionitem + """ + + SKU: Optional[str] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/appstoreserverapi/advancedcommercesku + """ + + description: Optional[str] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/appstoreserverapi/advancedcommercedescription + """ + + displayName: Optional[str] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/appstoreserverapi/advancedcommercedisplayname + """ + + offer: Optional[AdvancedCommerceOffer] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/appstoreserverapi/advancedcommerceoffer + """ + + price: Optional[int] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/appstoreserverapi/advancedcommerceprice + """ + + refunds: Optional[List[AdvancedCommerceRefund]] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/appstoreserverapi/advancedcommercerefunds + """ + + revocationDate: Optional[int] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/appstoreserverapi/revocationdate + """ diff --git a/appstoreserverlibrary/models/AlternateProduct.py b/appstoreserverlibrary/models/AlternateProduct.py new file mode 100644 index 00000000..d2c67c68 --- /dev/null +++ b/appstoreserverlibrary/models/AlternateProduct.py @@ -0,0 +1,41 @@ +# Copyright (c) 2025 Apple Inc. Licensed under MIT License. +from typing import Optional +from uuid import UUID + +from attr import define +import attr + +from .BillingPlanType import BillingPlanType +from .LibraryUtility import AttrsRawValueAware + +@define +class AlternateProduct(AttrsRawValueAware): + """ + A switch-plan message and product ID you provide in a real-time response to your Get Retention Message endpoint. + + https://developer.apple.com/documentation/retentionmessaging/alternateproduct + """ + + messageIdentifier: Optional[UUID] = attr.ib(default=None) + """ + The message identifier of the text to display in the switch-plan retention message. + + https://developer.apple.com/documentation/retentionmessaging/messageidentifier + """ + + productId: Optional[str] = attr.ib(default=None) + """ + The product identifier of the subscription the retention message suggests for your customer to switch to. + + https://developer.apple.com/documentation/retentionmessaging/productid + """ + + billingPlanType: Optional[BillingPlanType] = BillingPlanType.create_main_attr('rawBillingPlanType') + """ + https://developer.apple.com/documentation/retentionmessaging/billingplantype + """ + + rawBillingPlanType: Optional[str] = BillingPlanType.create_raw_attr('billingPlanType') + """ + See billingPlanType + """ diff --git a/appstoreserverlibrary/models/AppData.py b/appstoreserverlibrary/models/AppData.py new file mode 100644 index 00000000..e2a26a24 --- /dev/null +++ b/appstoreserverlibrary/models/AppData.py @@ -0,0 +1,49 @@ +# Copyright (c) 2025 Apple Inc. Licensed under MIT License. +from typing import Optional + +from attr import define +import attr + +from .Environment import Environment +from .LibraryUtility import AttrsRawValueAware + +@define +class AppData(AttrsRawValueAware): + """ + The object that contains the app metadata and signed app transaction information. + + https://developer.apple.com/documentation/appstoreservernotifications/appdata + """ + + appAppleId: Optional[int] = attr.ib(default=None) + """ + The unique identifier of the app that the notification applies to. + + https://developer.apple.com/documentation/appstoreservernotifications/appappleid + """ + + bundleId: Optional[str] = attr.ib(default=None) + """ + The bundle identifier of the app. + + https://developer.apple.com/documentation/appstoreservernotifications/bundleid + """ + + environment: Optional[Environment] = Environment.create_main_attr('rawEnvironment') + """ + The server environment that the notification applies to, either sandbox or production. + + https://developer.apple.com/documentation/appstoreservernotifications/environment + """ + + rawEnvironment: Optional[str] = Environment.create_raw_attr('environment') + """ + See environment + """ + + signedAppTransactionInfo: Optional[str] = attr.ib(default=None) + """ + App transaction information signed by the App Store, in JSON Web Signature (JWS) format. + + https://developer.apple.com/documentation/appstoreservernotifications/jwsapptransaction + """ diff --git a/appstoreserverlibrary/models/AppTransaction.py b/appstoreserverlibrary/models/AppTransaction.py index 7093a100..b8d31b6a 100644 --- a/appstoreserverlibrary/models/AppTransaction.py +++ b/appstoreserverlibrary/models/AppTransaction.py @@ -7,20 +7,22 @@ from .LibraryUtility import AttrsRawValueAware from .Environment import Environment +from .PurchasePlatform import PurchasePlatform @define class AppTransaction(AttrsRawValueAware): """ - Information that represents the customer’s purchase of the app, cryptographically signed by the App Store. - + A decoded payload that contains app transaction information. + https://developer.apple.com/documentation/storekit/apptransaction + https://developer.apple.com/documentation/appstoreserverapi/jwsapptransactiondecodedpayload """ receiptType: Optional[Environment] = Environment.create_main_attr('rawReceiptType') """ - The server environment that signs the app transaction. - - https://developer.apple.com/documentation/storekit/apptransaction/3963901-environment + The date that the App Store signed the JWS app transaction. + + https://developer.apple.com/documentation/appstoreserverapi/environment """ rawReceiptType: Optional[str] = Environment.create_raw_attr('receiptType') @@ -32,68 +34,87 @@ class AppTransaction(AttrsRawValueAware): """ The unique identifier the App Store uses to identify the app. - https://developer.apple.com/documentation/storekit/apptransaction/3954436-appid + https://developer.apple.com/documentation/appstoreserverapi/appappleid """ bundleId: Optional[str] = attr.ib(default=None) """ The bundle identifier that the app transaction applies to. - https://developer.apple.com/documentation/storekit/apptransaction/3954439-bundleid + https://developer.apple.com/documentation/appstoreserverapi/bundleid """ applicationVersion: Optional[str] = attr.ib(default=None) """ The app version that the app transaction applies to. - https://developer.apple.com/documentation/storekit/apptransaction/3954437-appversion + https://developer.apple.com/documentation/storekit/apptransaction/appversion """ versionExternalIdentifier: Optional[int] = attr.ib(default=None) """ The version external identifier of the app - https://developer.apple.com/documentation/storekit/apptransaction/3954438-appversionid + https://developer.apple.com/documentation/storekit/apptransaction/appversionid """ receiptCreationDate: Optional[int] = attr.ib(default=None) """ The date that the App Store signed the JWS app transaction. - https://developer.apple.com/documentation/storekit/apptransaction/3954449-signeddate + https://developer.apple.com/documentation/appstoreserverapi/receiptcreationdate """ originalPurchaseDate: Optional[int] = attr.ib(default=None) """ - The date the user originally purchased the app from the App Store. - - https://developer.apple.com/documentation/storekit/apptransaction/3954448-originalpurchasedate + The date the customer originally purchased the app from the App Store. + + https://developer.apple.com/documentation/appstoreserverapi/originalpurchasedate """ originalApplicationVersion: Optional[str] = attr.ib(default=None) """ The app version that the user originally purchased from the App Store. - https://developer.apple.com/documentation/storekit/apptransaction/3954447-originalappversion + https://developer.apple.com/documentation/appstoreserverapi/originalapplicationversion """ deviceVerification: Optional[str] = attr.ib(default=None) """ The Base64 device verification value to use to verify whether the app transaction belongs to the device. - https://developer.apple.com/documentation/storekit/apptransaction/3954441-deviceverification + https://developer.apple.com/documentation/storekit/apptransaction/deviceverification """ deviceVerificationNonce: Optional[str] = attr.ib(default=None) """ The UUID used to compute the device verification value. - https://developer.apple.com/documentation/storekit/apptransaction/3954442-deviceverificationnonce + https://developer.apple.com/documentation/storekit/apptransaction/deviceverificationnonce """ preorderDate: Optional[int] = attr.ib(default=None) """ The date the customer placed an order for the app before it's available in the App Store. - https://developer.apple.com/documentation/storekit/apptransaction/4013175-preorderdate + https://developer.apple.com/documentation/appstoreserverapi/preorderdate + """ + + appTransactionId: Optional[str] = attr.ib(default=None) + """ + The unique identifier of the app download transaction. + + https://developer.apple.com/documentation/appstoreserverapi/apptransactionid + """ + + originalPlatform: Optional[PurchasePlatform] = PurchasePlatform.create_main_attr('rawOriginalPlatform') + """ + The platform on which the customer originally purchased the app. + + https://developer.apple.com/documentation/appstoreserverapi/originalplatform + """ + + rawOriginalPlatform: Optional[str] = PurchasePlatform.create_raw_attr('originalPlatform') + """ + See originalPlatform """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/AppTransactionInfoResponse.py b/appstoreserverlibrary/models/AppTransactionInfoResponse.py new file mode 100644 index 00000000..5deb7fde --- /dev/null +++ b/appstoreserverlibrary/models/AppTransactionInfoResponse.py @@ -0,0 +1,20 @@ +# Copyright (c) 2025 Apple Inc. Licensed under MIT License. +from typing import Optional + +from attr import define +import attr + +@define +class AppTransactionInfoResponse: + """ + A response that contains signed app transaction information for a customer. + + https://developer.apple.com/documentation/appstoreserverapi/apptransactioninforesponse + """ + + signedAppTransactionInfo: Optional[str] = attr.ib(default=None) + """ + A customer’s app transaction information, signed by Apple, in JSON Web Signature (JWS) format. + + https://developer.apple.com/documentation/appstoreserverapi/jwsapptransaction + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/BillingPlanType.py b/appstoreserverlibrary/models/BillingPlanType.py new file mode 100644 index 00000000..82ab1052 --- /dev/null +++ b/appstoreserverlibrary/models/BillingPlanType.py @@ -0,0 +1,12 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from enum import Enum + +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class BillingPlanType(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): + """ + https://developer.apple.com/documentation/appstoreserverapi/billingplantype + """ + BILLED_UPFRONT = "BILLED_UPFRONT" + MONTHLY = "MONTHLY" diff --git a/appstoreserverlibrary/models/BulletPoint.py b/appstoreserverlibrary/models/BulletPoint.py new file mode 100644 index 00000000..1380c34f --- /dev/null +++ b/appstoreserverlibrary/models/BulletPoint.py @@ -0,0 +1,35 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from uuid import UUID + +from attr import define +import attr + +@define +class BulletPoint: + """ + The text and its bullet-point image to include in a retention message’s bulleted list. + + https://developer.apple.com/documentation/retentionmessaging/bulletpoint + """ + + text: str = attr.ib(validator=attr.validators.max_len(66)) + """ + The text of the individual bullet point. + + https://developer.apple.com/documentation/retentionmessaging/bulletpointtext + """ + + imageIdentifier: UUID = attr.ib() + """ + The identifier of the image to use as the bullet point. + + https://developer.apple.com/documentation/retentionmessaging/imageidentifier + """ + + altText: str = attr.ib(validator=attr.validators.max_len(150)) + """ + The alternative text you provide for the corresponding image of the bullet point. + + https://developer.apple.com/documentation/retentionmessaging/alttext + """ diff --git a/appstoreserverlibrary/models/ConsumptionRequest.py b/appstoreserverlibrary/models/ConsumptionRequest.py index c9797106..ec83073a 100644 --- a/appstoreserverlibrary/models/ConsumptionRequest.py +++ b/appstoreserverlibrary/models/ConsumptionRequest.py @@ -1,140 +1,62 @@ -# Copyright (c) 2023 Apple Inc. Licensed under MIT License. +# Copyright (c) 2025 Apple Inc. Licensed under MIT License. from typing import Optional from attr import define import attr -from .AccountTenure import AccountTenure -from .ConsumptionStatus import ConsumptionStatus from .DeliveryStatus import DeliveryStatus from .LibraryUtility import AttrsRawValueAware -from .LifetimeDollarsPurchased import LifetimeDollarsPurchased -from .LifetimeDollarsRefunded import LifetimeDollarsRefunded -from .Platform import Platform -from .PlayTime import PlayTime -from .UserStatus import UserStatus +from .RefundPreference import RefundPreference @define class ConsumptionRequest(AttrsRawValueAware): """ - The request body containing consumption information. - + The request body that contains consumption information for an In-App Purchase. + https://developer.apple.com/documentation/appstoreserverapi/consumptionrequest """ - customerConsented: Optional[bool] = attr.ib(default=None) + customerConsented: bool = attr.ib() """ A Boolean value that indicates whether the customer consented to provide consumption data to the App Store. - - https://developer.apple.com/documentation/appstoreserverapi/customerconsented - """ - - consumptionStatus: Optional[ConsumptionStatus] = ConsumptionStatus.create_main_attr('rawConsumptionStatus') - """ - A value that indicates the extent to which the customer consumed the in-app purchase. - - https://developer.apple.com/documentation/appstoreserverapi/consumptionstatus - """ - - rawConsumptionStatus: Optional[int] = ConsumptionStatus.create_raw_attr('consumptionStatus') - """ - See consumptionStatus - """ - - platform: Optional[Platform] = Platform.create_main_attr('rawPlatform') - """ - A value that indicates the platform on which the customer consumed the in-app purchase. - - https://developer.apple.com/documentation/appstoreserverapi/platform - """ - rawPlatform: Optional[int] = Platform.create_raw_attr('platform') - """ - See platform + https://developer.apple.com/documentation/appstoreserverapi/customerconsented """ - sampleContentProvided: Optional[bool] = attr.ib(default=None) + sampleContentProvided: bool = attr.ib() """ A Boolean value that indicates whether you provided, prior to its purchase, a free sample or trial of the content, or information about its functionality. - + https://developer.apple.com/documentation/appstoreserverapi/samplecontentprovided """ - deliveryStatus: Optional[DeliveryStatus] = DeliveryStatus.create_main_attr('rawDeliveryStatus') + deliveryStatus: Optional[DeliveryStatus] = DeliveryStatus.create_main_attr('rawDeliveryStatus', raw_required=True) """ A value that indicates whether the app successfully delivered an in-app purchase that works properly. - - https://developer.apple.com/documentation/appstoreserverapi/deliverystatus - """ - - rawDeliveryStatus: Optional[int] = DeliveryStatus.create_raw_attr('deliveryStatus') - """ - See deliveryStatus - """ - appAccountToken: Optional[str] = attr.ib(default=None) - """ - The UUID that an app optionally generates to map a customer's in-app purchase with its resulting App Store transaction. - - https://developer.apple.com/documentation/appstoreserverapi/appaccounttoken - """ - - accountTenure: Optional[AccountTenure] = AccountTenure.create_main_attr('rawAccountTenure') - """ - The age of the customer's account. - - https://developer.apple.com/documentation/appstoreserverapi/accounttenure - """ - - rawAccountTenure: Optional[int] = AccountTenure.create_raw_attr('accountTenure') - """ - See accountTenure - """ - - playTime: Optional[PlayTime] = PlayTime.create_main_attr('rawPlayTime') + https://developer.apple.com/documentation/appstoreserverapi/deliverystatus """ - A value that indicates the amount of time that the customer used the app. - https://developer.apple.com/documentation/appstoreserverapi/consumptionrequest + consumptionPercentage: Optional[int] = attr.ib(default=None) """ + An integer that indicates the percentage, in milliunits, of the In-App Purchase the customer consumed. - rawPlayTime: Optional[int] = PlayTime.create_raw_attr('playTime') - """ - See playTime + https://developer.apple.com/documentation/appstoreserverapi/consumptionpercentage """ - lifetimeDollarsRefunded: Optional[LifetimeDollarsRefunded] = LifetimeDollarsRefunded.create_main_attr('rawLifetimeDollarsRefunded') + rawDeliveryStatus: str = DeliveryStatus.create_raw_attr('deliveryStatus', required=True) """ - A value that indicates the total amount, in USD, of refunds the customer has received, in your app, across all platforms. - - https://developer.apple.com/documentation/appstoreserverapi/lifetimedollarsrefunded + See deliveryStatus """ - rawLifetimeDollarsRefunded: Optional[int] = LifetimeDollarsRefunded.create_raw_attr('lifetimeDollarsRefunded') - """ - See lifetimeDollarsRefunded + refundPreference: Optional[RefundPreference] = RefundPreference.create_main_attr('rawRefundPreference') """ + A value that indicates your preferred outcome for the refund request. - lifetimeDollarsPurchased: Optional[LifetimeDollarsPurchased] = LifetimeDollarsPurchased.create_main_attr('rawLifetimeDollarsPurchased') - """ - A value that indicates the total amount, in USD, of in-app purchases the customer has made in your app, across all platforms. - - https://developer.apple.com/documentation/appstoreserverapi/lifetimedollarspurchased + https://developer.apple.com/documentation/appstoreserverapi/refundpreference """ - rawLifetimeDollarsPurchased: Optional[int] = LifetimeDollarsPurchased.create_raw_attr('lifetimeDollarsPurchased') + rawRefundPreference: Optional[str] = RefundPreference.create_raw_attr('refundPreference') """ - See lifetimeDollarsPurchased - """ - - userStatus: Optional[UserStatus] = UserStatus.create_main_attr('rawUserStatus') - """ - The status of the customer's account. - - https://developer.apple.com/documentation/appstoreserverapi/userstatus - """ - - rawUserStatus: Optional[int] = UserStatus.create_raw_attr('userStatus') + See refundPreference """ - See userStatus - """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/ConsumptionRequestReason.py b/appstoreserverlibrary/models/ConsumptionRequestReason.py new file mode 100644 index 00000000..9907f117 --- /dev/null +++ b/appstoreserverlibrary/models/ConsumptionRequestReason.py @@ -0,0 +1,17 @@ +# Copyright (c) 2024 Apple Inc. Licensed under MIT License. + +from enum import Enum + +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class ConsumptionRequestReason(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): + """ + The customer-provided reason for a refund request. + + https://developer.apple.com/documentation/appstoreservernotifications/consumptionrequestreason + """ + UNINTENDED_PURCHASE = "UNINTENDED_PURCHASE" + FULFILLMENT_ISSUE = "FULFILLMENT_ISSUE" + UNSATISFIED_WITH_PURCHASE = "UNSATISFIED_WITH_PURCHASE" + LEGAL = "LEGAL" + OTHER = "OTHER" diff --git a/appstoreserverlibrary/models/ConsumptionRequestV1.py b/appstoreserverlibrary/models/ConsumptionRequestV1.py new file mode 100644 index 00000000..43da60a6 --- /dev/null +++ b/appstoreserverlibrary/models/ConsumptionRequestV1.py @@ -0,0 +1,156 @@ +# Copyright (c) 2023 Apple Inc. Licensed under MIT License. +from typing import Optional + +from attr import define +import attr + +from .AccountTenure import AccountTenure +from .ConsumptionStatus import ConsumptionStatus +from .DeliveryStatusV1 import DeliveryStatusV1 +from .LibraryUtility import AttrsRawValueAware +from .LifetimeDollarsPurchased import LifetimeDollarsPurchased +from .LifetimeDollarsRefunded import LifetimeDollarsRefunded +from .Platform import Platform +from .PlayTime import PlayTime +from .RefundPreferenceV1 import RefundPreferenceV1 +from .UserStatus import UserStatus + +@define +class ConsumptionRequestV1(AttrsRawValueAware): + """ + The request body containing consumption information. + + .. deprecated:: + Use :class:`ConsumptionRequest` instead. + + https://developer.apple.com/documentation/appstoreserverapi/consumptionrequestv1 + """ + + customerConsented: Optional[bool] = attr.ib(default=None) + """ + A Boolean value that indicates whether the customer consented to provide consumption data to the App Store. + + https://developer.apple.com/documentation/appstoreserverapi/customerconsented + """ + + consumptionStatus: Optional[ConsumptionStatus] = ConsumptionStatus.create_main_attr('rawConsumptionStatus') + """ + A value that indicates the extent to which the customer consumed the in-app purchase. + + https://developer.apple.com/documentation/appstoreserverapi/consumptionstatus + """ + + rawConsumptionStatus: Optional[int] = ConsumptionStatus.create_raw_attr('consumptionStatus') + """ + See consumptionStatus + """ + + platform: Optional[Platform] = Platform.create_main_attr('rawPlatform') + """ + A value that indicates the platform on which the customer consumed the in-app purchase. + + https://developer.apple.com/documentation/appstoreserverapi/platform + """ + + rawPlatform: Optional[int] = Platform.create_raw_attr('platform') + """ + See platform + """ + + sampleContentProvided: Optional[bool] = attr.ib(default=None) + """ + A Boolean value that indicates whether you provided, prior to its purchase, a free sample or trial of the content, or information about its functionality. + + https://developer.apple.com/documentation/appstoreserverapi/samplecontentprovided + """ + + deliveryStatus: Optional[DeliveryStatusV1] = DeliveryStatusV1.create_main_attr('rawDeliveryStatus') + """ + A value that indicates whether the app successfully delivered an in-app purchase that works properly. + + https://developer.apple.com/documentation/appstoreserverapi/deliverystatus + """ + + rawDeliveryStatus: Optional[int] = DeliveryStatusV1.create_raw_attr('deliveryStatus') + """ + See deliveryStatus + """ + + appAccountToken: Optional[str] = attr.ib(default=None) + """ + The UUID that an app optionally generates to map a customer's in-app purchase with its resulting App Store transaction. + + https://developer.apple.com/documentation/appstoreserverapi/appaccounttoken + """ + + accountTenure: Optional[AccountTenure] = AccountTenure.create_main_attr('rawAccountTenure') + """ + The age of the customer's account. + + https://developer.apple.com/documentation/appstoreserverapi/accounttenure + """ + + rawAccountTenure: Optional[int] = AccountTenure.create_raw_attr('accountTenure') + """ + See accountTenure + """ + + playTime: Optional[PlayTime] = PlayTime.create_main_attr('rawPlayTime') + """ + A value that indicates the amount of time that the customer used the app. + + https://developer.apple.com/documentation/appstoreserverapi/consumptionrequest + """ + + rawPlayTime: Optional[int] = PlayTime.create_raw_attr('playTime') + """ + See playTime + """ + + lifetimeDollarsRefunded: Optional[LifetimeDollarsRefunded] = LifetimeDollarsRefunded.create_main_attr('rawLifetimeDollarsRefunded') + """ + A value that indicates the total amount, in USD, of refunds the customer has received, in your app, across all platforms. + + https://developer.apple.com/documentation/appstoreserverapi/lifetimedollarsrefunded + """ + + rawLifetimeDollarsRefunded: Optional[int] = LifetimeDollarsRefunded.create_raw_attr('lifetimeDollarsRefunded') + """ + See lifetimeDollarsRefunded + """ + + lifetimeDollarsPurchased: Optional[LifetimeDollarsPurchased] = LifetimeDollarsPurchased.create_main_attr('rawLifetimeDollarsPurchased') + """ + A value that indicates the total amount, in USD, of in-app purchases the customer has made in your app, across all platforms. + + https://developer.apple.com/documentation/appstoreserverapi/lifetimedollarspurchased + """ + + rawLifetimeDollarsPurchased: Optional[int] = LifetimeDollarsPurchased.create_raw_attr('lifetimeDollarsPurchased') + """ + See lifetimeDollarsPurchased + """ + + userStatus: Optional[UserStatus] = UserStatus.create_main_attr('rawUserStatus') + """ + The status of the customer's account. + + https://developer.apple.com/documentation/appstoreserverapi/userstatus + """ + + rawUserStatus: Optional[int] = UserStatus.create_raw_attr('userStatus') + """ + See userStatus + """ + + refundPreference: Optional[RefundPreferenceV1] = RefundPreferenceV1.create_main_attr('rawRefundPreference') + """ + A value that indicates your preference, based on your operational logic, as to whether Apple should grant the refund. + + https://developer.apple.com/documentation/appstoreserverapi/refundpreference + """ + + rawRefundPreference: Optional[int] = RefundPreferenceV1.create_raw_attr('refundPreference') + """ + See refundPreference + """ diff --git a/appstoreserverlibrary/models/Data.py b/appstoreserverlibrary/models/Data.py index 28856d2f..c69e5a02 100644 --- a/appstoreserverlibrary/models/Data.py +++ b/appstoreserverlibrary/models/Data.py @@ -4,6 +4,7 @@ from attr import define import attr +from .ConsumptionRequestReason import ConsumptionRequestReason from .Environment import Environment from .Status import Status from .LibraryUtility import AttrsRawValueAware @@ -71,4 +72,16 @@ class Data(AttrsRawValueAware): rawStatus: Optional[int] = Status.create_raw_attr('status') """ See status + """ + + consumptionRequestReason: Optional[ConsumptionRequestReason] = ConsumptionRequestReason.create_main_attr('rawConsumptionRequestReason') + """ + The reason the customer requested the refund. + + https://developer.apple.com/documentation/appstoreservernotifications/consumptionrequestreason + """ + + rawConsumptionRequestReason: Optional[str] = ConsumptionRequestReason.create_raw_attr('consumptionRequestReason') + """ + See consumptionRequestReason """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/DecodedRealtimeRequestBody.py b/appstoreserverlibrary/models/DecodedRealtimeRequestBody.py new file mode 100644 index 00000000..45812b48 --- /dev/null +++ b/appstoreserverlibrary/models/DecodedRealtimeRequestBody.py @@ -0,0 +1,73 @@ +# Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +from typing import Optional +from uuid import UUID + +from attr import define +import attr + +from .Environment import Environment +from .LibraryUtility import AttrsRawValueAware + +@define +class DecodedRealtimeRequestBody(AttrsRawValueAware): + """ + The decoded request body the App Store sends to your server to request a real-time retention message. + + https://developer.apple.com/documentation/retentionmessaging/decodedrealtimerequestbody + """ + + originalTransactionId: str = attr.ib() + """ + The original transaction identifier of the customer's subscription. + + https://developer.apple.com/documentation/retentionmessaging/originaltransactionid + """ + + appAppleId: int = attr.ib() + """ + The unique identifier of the app in the App Store. + + https://developer.apple.com/documentation/retentionmessaging/appappleid + """ + + productId: str = attr.ib() + """ + The unique identifier of the auto-renewable subscription. + + https://developer.apple.com/documentation/retentionmessaging/productid + """ + + userLocale: str = attr.ib() + """ + The device's locale. + + https://developer.apple.com/documentation/retentionmessaging/locale + """ + + requestIdentifier: UUID = attr.ib() + """ + A UUID the App Store server creates to uniquely identify each request. + + https://developer.apple.com/documentation/retentionmessaging/requestidentifier + """ + + signedDate: int = attr.ib() + """ + The UNIX time, in milliseconds, that the App Store signed the JSON Web Signature (JWS) data. + + https://developer.apple.com/documentation/retentionmessaging/signeddate + """ + + environment: Optional[Environment] = Environment.create_main_attr('rawEnvironment', raw_required=True) + """ + The server environment, either sandbox or production. + + https://developer.apple.com/documentation/retentionmessaging/environment + """ + + rawEnvironment: str = Environment.create_raw_attr('environment', required=True) + """ + See environment + """ + diff --git a/appstoreserverlibrary/models/DefaultConfigurationRequest.py b/appstoreserverlibrary/models/DefaultConfigurationRequest.py new file mode 100644 index 00000000..2660163a --- /dev/null +++ b/appstoreserverlibrary/models/DefaultConfigurationRequest.py @@ -0,0 +1,24 @@ +# Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +from typing import Optional +from uuid import UUID + +from attr import define +import attr + +@define +class DefaultConfigurationRequest: + """ + The request body that contains the default configuration information. + + https://developer.apple.com/documentation/retentionmessaging/defaultconfigurationrequest + """ + + messageIdentifier: Optional[UUID] = attr.ib(default=None) + """ + The message identifier of the message to configure as a default message. + + Note: In a future version, this field will become required. + + https://developer.apple.com/documentation/retentionmessaging/messageidentifier + """ diff --git a/appstoreserverlibrary/models/DefaultConfigurationResponse.py b/appstoreserverlibrary/models/DefaultConfigurationResponse.py new file mode 100644 index 00000000..56d714ea --- /dev/null +++ b/appstoreserverlibrary/models/DefaultConfigurationResponse.py @@ -0,0 +1,21 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from uuid import UUID + +from attr import define +import attr + +@define +class DefaultConfigurationResponse: + """ + The response body that contains the default configuration information. + + https://developer.apple.com/documentation/retentionmessaging/defaultconfigurationresponse + """ + + messageIdentifier: UUID = attr.ib() + """ + The message identifier of the retention message you configured as a default. + + https://developer.apple.com/documentation/retentionmessaging/messageidentifier + """ diff --git a/appstoreserverlibrary/models/DeliveryStatus.py b/appstoreserverlibrary/models/DeliveryStatus.py index eee99095..ad29c3c1 100644 --- a/appstoreserverlibrary/models/DeliveryStatus.py +++ b/appstoreserverlibrary/models/DeliveryStatus.py @@ -1,18 +1,17 @@ -# Copyright (c) 2023 Apple Inc. Licensed under MIT License. +# Copyright (c) 2025 Apple Inc. Licensed under MIT License. -from enum import IntEnum +from enum import Enum from .LibraryUtility import AppStoreServerLibraryEnumMeta -class DeliveryStatus(IntEnum, metaclass=AppStoreServerLibraryEnumMeta): +class DeliveryStatus(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): """ A value that indicates whether the app successfully delivered an in-app purchase that works properly. - + https://developer.apple.com/documentation/appstoreserverapi/deliverystatus """ - DELIVERED_AND_WORKING_PROPERLY = 0 - DID_NOT_DELIVER_DUE_TO_QUALITY_ISSUE = 1 - DELIVERED_WRONG_ITEM = 2 - DID_NOT_DELIVER_DUE_TO_SERVER_OUTAGE = 3 - DID_NOT_DELIVER_DUE_TO_IN_GAME_CURRENCY_CHANGE = 4 - DID_NOT_DELIVER_FOR_OTHER_REASON = 5 + DELIVERED = "DELIVERED" + UNDELIVERED_QUALITY_ISSUE = "UNDELIVERED_QUALITY_ISSUE" + UNDELIVERED_WRONG_ITEM = "UNDELIVERED_WRONG_ITEM" + UNDELIVERED_SERVER_OUTAGE = "UNDELIVERED_SERVER_OUTAGE" + UNDELIVERED_OTHER = "UNDELIVERED_OTHER" diff --git a/appstoreserverlibrary/models/DeliveryStatusV1.py b/appstoreserverlibrary/models/DeliveryStatusV1.py new file mode 100644 index 00000000..8195d101 --- /dev/null +++ b/appstoreserverlibrary/models/DeliveryStatusV1.py @@ -0,0 +1,21 @@ +# Copyright (c) 2023 Apple Inc. Licensed under MIT License. + +from enum import IntEnum + +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class DeliveryStatusV1(IntEnum, metaclass=AppStoreServerLibraryEnumMeta): + """ + A value that indicates whether the app successfully delivered an in-app purchase that works properly. + + .. deprecated:: + Use :class:`DeliveryStatus` instead. + + https://developer.apple.com/documentation/appstoreserverapi/deliverystatusv1 + """ + DELIVERED_AND_WORKING_PROPERLY = 0 + DID_NOT_DELIVER_DUE_TO_QUALITY_ISSUE = 1 + DELIVERED_WRONG_ITEM = 2 + DID_NOT_DELIVER_DUE_TO_SERVER_OUTAGE = 3 + DID_NOT_DELIVER_DUE_TO_IN_GAME_CURRENCY_CHANGE = 4 + DID_NOT_DELIVER_FOR_OTHER_REASON = 5 diff --git a/appstoreserverlibrary/models/Environment.py b/appstoreserverlibrary/models/Environment.py index e5625a11..f3eb9dc5 100644 --- a/appstoreserverlibrary/models/Environment.py +++ b/appstoreserverlibrary/models/Environment.py @@ -13,4 +13,4 @@ class Environment(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): SANDBOX = "Sandbox" PRODUCTION = "Production" XCODE = "Xcode" - LOCAL_TESTING = "LocalTesting" + LOCAL_TESTING = "LocalTesting" # Used for unit testing diff --git a/appstoreserverlibrary/models/ExtendRenewalDateRequest.py b/appstoreserverlibrary/models/ExtendRenewalDateRequest.py index 933944e3..c59c952a 100644 --- a/appstoreserverlibrary/models/ExtendRenewalDateRequest.py +++ b/appstoreserverlibrary/models/ExtendRenewalDateRequest.py @@ -17,7 +17,7 @@ class ExtendRenewalDateRequest: extendByDays: Optional[int] = attr.ib(default=None) """ The number of days to extend the subscription renewal date. - + https://developer.apple.com/documentation/appstoreserverapi/extendbydays maximum: 90 """ diff --git a/appstoreserverlibrary/models/ExternalPurchaseToken.py b/appstoreserverlibrary/models/ExternalPurchaseToken.py new file mode 100644 index 00000000..1a8989d6 --- /dev/null +++ b/appstoreserverlibrary/models/ExternalPurchaseToken.py @@ -0,0 +1,43 @@ +# Copyright (c) 2024 Apple Inc. Licensed under MIT License. +from typing import Optional + +from attr import define +import attr + +from .LibraryUtility import AttrsRawValueAware + +@define +class ExternalPurchaseToken(AttrsRawValueAware): + """ + The payload data that contains an external purchase token. + + https://developer.apple.com/documentation/appstoreservernotifications/externalpurchasetoken + """ + + externalPurchaseId: Optional[str] = attr.ib(default=None) + """ + The field of an external purchase token that uniquely identifies the token. + + https://developer.apple.com/documentation/appstoreservernotifications/externalpurchaseid + """ + + tokenCreationDate: Optional[int] = attr.ib(default=None) + """ + The field of an external purchase token that contains the UNIX date, in milliseconds, when the system created the token. + + https://developer.apple.com/documentation/appstoreservernotifications/tokencreationdate + """ + + appAppleId: Optional[int] = attr.ib(default=None) + """ + The unique identifier of an app in the App Store. + + https://developer.apple.com/documentation/appstoreservernotifications/appappleid + """ + + bundleId: Optional[str] = attr.ib(default=None) + """ + The bundle identifier of an app. + + https://developer.apple.com/documentation/appstoreservernotifications/bundleid + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/GetImageListResponse.py b/appstoreserverlibrary/models/GetImageListResponse.py new file mode 100644 index 00000000..6eb2747f --- /dev/null +++ b/appstoreserverlibrary/models/GetImageListResponse.py @@ -0,0 +1,23 @@ +# Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +from typing import Optional, List + +from attr import define +import attr + +from .GetImageListResponseItem import GetImageListResponseItem + +@define +class GetImageListResponse: + """ + A response that contains status information for all images. + + https://developer.apple.com/documentation/retentionmessaging/getimagelistresponse + """ + + imageIdentifiers: Optional[List[GetImageListResponseItem]] = attr.ib(default=None) + """ + An array of all image identifiers and their image state. + + https://developer.apple.com/documentation/retentionmessaging/getimagelistresponseitem + """ diff --git a/appstoreserverlibrary/models/GetImageListResponseItem.py b/appstoreserverlibrary/models/GetImageListResponseItem.py new file mode 100644 index 00000000..4c582a3a --- /dev/null +++ b/appstoreserverlibrary/models/GetImageListResponseItem.py @@ -0,0 +1,50 @@ +# Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +from typing import Optional +from uuid import UUID + +from attr import define +import attr + +from .ImageSize import ImageSize +from .ImageState import ImageState +from .LibraryUtility import AttrsRawValueAware + +@define +class GetImageListResponseItem(AttrsRawValueAware): + """ + An image identifier and state information for an image. + + https://developer.apple.com/documentation/retentionmessaging/getimagelistresponseitem + """ + + imageIdentifier: Optional[UUID] = attr.ib(default=None) + """ + The identifier of the image. + + https://developer.apple.com/documentation/retentionmessaging/imageidentifier + """ + + imageState: Optional[ImageState] = ImageState.create_main_attr('rawImageState') + """ + The current state of the image. + + https://developer.apple.com/documentation/retentionmessaging/imagestate + """ + + rawImageState: Optional[str] = ImageState.create_raw_attr('imageState') + """ + See imageState + """ + + imageSize: Optional[ImageSize] = ImageSize.create_main_attr('rawImageSize') + """ + The size of the image. + + https://developer.apple.com/documentation/retentionmessaging/imagesize + """ + + rawImageSize: Optional[str] = ImageSize.create_raw_attr('imageSize') + """ + See imageSize + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/GetMessageListResponse.py b/appstoreserverlibrary/models/GetMessageListResponse.py new file mode 100644 index 00000000..0fdbd037 --- /dev/null +++ b/appstoreserverlibrary/models/GetMessageListResponse.py @@ -0,0 +1,23 @@ +# Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +from typing import Optional, List + +from attr import define +import attr + +from .GetMessageListResponseItem import GetMessageListResponseItem + +@define +class GetMessageListResponse: + """ + A response that contains status information for all messages. + + https://developer.apple.com/documentation/retentionmessaging/getmessagelistresponse + """ + + messageIdentifiers: Optional[List[GetMessageListResponseItem]] = attr.ib(default=None) + """ + An array of all message identifiers and their message state. + + https://developer.apple.com/documentation/retentionmessaging/getmessagelistresponseitem + """ diff --git a/appstoreserverlibrary/models/GetMessageListResponseItem.py b/appstoreserverlibrary/models/GetMessageListResponseItem.py new file mode 100644 index 00000000..af810eeb --- /dev/null +++ b/appstoreserverlibrary/models/GetMessageListResponseItem.py @@ -0,0 +1,37 @@ +# Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +from typing import Optional +from uuid import UUID + +from attr import define +import attr + +from .MessageState import MessageState +from .LibraryUtility import AttrsRawValueAware + +@define +class GetMessageListResponseItem(AttrsRawValueAware): + """ + A message identifier and status information for a message. + + https://developer.apple.com/documentation/retentionmessaging/getmessagelistresponseitem + """ + + messageIdentifier: Optional[UUID] = attr.ib(default=None) + """ + The identifier of the message. + + https://developer.apple.com/documentation/retentionmessaging/messageidentifier + """ + + messageState: Optional[MessageState] = MessageState.create_main_attr('rawMessageState') + """ + The current state of the message. + + https://developer.apple.com/documentation/retentionmessaging/messageState + """ + + rawMessageState: Optional[str] = MessageState.create_raw_attr('messageState') + """ + See messageState + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/HeaderPosition.py b/appstoreserverlibrary/models/HeaderPosition.py new file mode 100644 index 00000000..7c9519c7 --- /dev/null +++ b/appstoreserverlibrary/models/HeaderPosition.py @@ -0,0 +1,14 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from enum import Enum + +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class HeaderPosition(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): + """ + The position where the header text appears in a message. + + https://developer.apple.com/documentation/retentionmessaging/headerposition + """ + ABOVE_BODY = "ABOVE_BODY" + ABOVE_IMAGE = "ABOVE_IMAGE" diff --git a/appstoreserverlibrary/models/HelperValidationUtils.py b/appstoreserverlibrary/models/HelperValidationUtils.py new file mode 100644 index 00000000..3469fbad --- /dev/null +++ b/appstoreserverlibrary/models/HelperValidationUtils.py @@ -0,0 +1,101 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from typing import TypeVar + +T = TypeVar('T') + + +class HelperValidationUtils: + MAXIMUM_DESCRIPTION_LENGTH = 45 + MAXIMUM_DISPLAY_NAME_LENGTH = 30 + MAXIMUM_SKU_LENGTH = 128 + MIN_PERIOD = 1 + MAX_PERIOD = 12 + + @staticmethod + def description_validator(instance, attribute, value): + """ + Validates description is not None and does not exceed maximum length. + + Raises: + ValueError: If description exceeds maximum length + """ + if len(value) > HelperValidationUtils.MAXIMUM_DESCRIPTION_LENGTH: + raise ValueError( + f"Description length cannot exceed " + f"{HelperValidationUtils.MAXIMUM_DESCRIPTION_LENGTH} characters" + ) + + @staticmethod + def display_name_validator(instance, attribute, value): + """ + Validates display name is not None and does not exceed maximum length. + + Raises: + ValueError: If display name exceeds maximum length + """ + if len(value) > HelperValidationUtils.MAXIMUM_DISPLAY_NAME_LENGTH: + raise ValueError( + f"Display name length cannot exceed " + f"{HelperValidationUtils.MAXIMUM_DISPLAY_NAME_LENGTH} characters" + ) + + @staticmethod + def sku_validator(instance, attribute, value): + """ + Validates SKU does not exceed maximum length. + + Raises: + ValueError: If SKU exceeds maximum length + """ + if len(value) > HelperValidationUtils.MAXIMUM_SKU_LENGTH: + raise ValueError( + f"SKU length cannot exceed " + f"{HelperValidationUtils.MAXIMUM_SKU_LENGTH} characters" + ) + + @staticmethod + def period_count_validator(instance, attribute, value): + """ + Validates periodCount is not None and between `MIN_PERIOD` and `MAX_PERIOD` inclusive. + + Raises: + ValueError: If period_count is out of range + """ + if (value < HelperValidationUtils.MIN_PERIOD or + value > HelperValidationUtils.MAX_PERIOD): + raise ValueError( + f"Period count must be between " + f"{HelperValidationUtils.MIN_PERIOD} and " + f"{HelperValidationUtils.MAX_PERIOD}" + ) + + @staticmethod + def items_validator(instance, attribute, value): + """ + Validates a list of items is not None, not empty, and contains no None elements. + + Raises: + ValueError: If list is empty or contains None elements + """ + if not value: + raise ValueError("Items list cannot be empty") + + for i, item in enumerate(value): + if item is None: + raise ValueError(f"Item at index {i} in the list cannot be None") + + @staticmethod + def dependent_skus_validator(instance, attribute, value): + """ + Validates that each SKU in the dependentSKUs list does not exceed maximum length. + + Raises: + ValueError: If any SKU exceeds maximum length + """ + for sku in value: + if len(sku) > HelperValidationUtils.MAXIMUM_SKU_LENGTH: + raise ValueError( + f"SKU length cannot exceed " + f"{HelperValidationUtils.MAXIMUM_SKU_LENGTH} characters" + ) diff --git a/appstoreserverlibrary/models/ImageSize.py b/appstoreserverlibrary/models/ImageSize.py new file mode 100644 index 00000000..1669d3e6 --- /dev/null +++ b/appstoreserverlibrary/models/ImageSize.py @@ -0,0 +1,14 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from enum import Enum + +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class ImageSize(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): + """ + The size of an image. + + https://developer.apple.com/documentation/retentionmessaging/imagesize + """ + FULL_SIZE = "FULL_SIZE" + BULLET_POINT = "BULLET_POINT" diff --git a/appstoreserverlibrary/models/ImageState.py b/appstoreserverlibrary/models/ImageState.py new file mode 100644 index 00000000..9557b25f --- /dev/null +++ b/appstoreserverlibrary/models/ImageState.py @@ -0,0 +1,15 @@ +# Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +from enum import Enum + +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class ImageState(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): + """ + The approval state of an image. + + https://developer.apple.com/documentation/retentionmessaging/imagestate + """ + PENDING = "PENDING" + APPROVED = "APPROVED" + REJECTED = "REJECTED" diff --git a/appstoreserverlibrary/models/JWSRenewalInfoDecodedPayload.py b/appstoreserverlibrary/models/JWSRenewalInfoDecodedPayload.py index 023892e6..a9215bab 100644 --- a/appstoreserverlibrary/models/JWSRenewalInfoDecodedPayload.py +++ b/appstoreserverlibrary/models/JWSRenewalInfoDecodedPayload.py @@ -1,5 +1,5 @@ # Copyright (c) 2023 Apple Inc. Licensed under MIT License. -from typing import Optional +from typing import List, Optional from attr import define import attr @@ -10,6 +10,10 @@ from .LibraryUtility import AttrsRawValueAware from .OfferType import OfferType from .PriceIncreaseStatus import PriceIncreaseStatus +from .OfferDiscountType import OfferDiscountType +from .RenewalBillingPlanType import RenewalBillingPlanType +from .AdvancedCommerceRenewalInfo import AdvancedCommerceRenewalInfo +from .RenewalCommitmentInfo import RenewalCommitmentInfo @define class JWSRenewalInfoDecodedPayload(AttrsRawValueAware): @@ -92,7 +96,7 @@ class JWSRenewalInfoDecodedPayload(AttrsRawValueAware): offerType: Optional[OfferType] = OfferType.create_main_attr('rawOfferType') """ - The type of the subscription offer. + The type of subscription offer. https://developer.apple.com/documentation/appstoreserverapi/offertype """ @@ -104,7 +108,7 @@ class JWSRenewalInfoDecodedPayload(AttrsRawValueAware): offerIdentifier: Optional[str] = attr.ib(default=None) """ - The identifier that contains the promo code or the promotional offer identifier. + The offer code or the promotional offer identifier. https://developer.apple.com/documentation/appstoreserverapi/offeridentifier """ @@ -140,4 +144,80 @@ class JWSRenewalInfoDecodedPayload(AttrsRawValueAware): The UNIX time, in milliseconds, that the most recent auto-renewable subscription purchase expires. https://developer.apple.com/documentation/appstoreserverapi/renewaldate + """ + + currency: Optional[str] = attr.ib(default=None) + """ + The currency code for the renewalPrice of the subscription. + + https://developer.apple.com/documentation/appstoreserverapi/currency + """ + + renewalPrice: Optional[int] = attr.ib(default=None) + """ + The renewal price, in milliunits, of the auto-renewable subscription that renews at the next billing period. + + https://developer.apple.com/documentation/appstoreserverapi/renewalprice + """ + + offerDiscountType: Optional[OfferDiscountType] = OfferDiscountType.create_main_attr('rawOfferDiscountType') + """ + The payment mode you configure for the offer. + + https://developer.apple.com/documentation/appstoreserverapi/offerdiscounttype + """ + + rawOfferDiscountType: Optional[str] = OfferDiscountType.create_raw_attr('offerDiscountType') + """ + See offerDiscountType + """ + + eligibleWinBackOfferIds: Optional[List[str]] = attr.ib(default=None) + """ + An array of win-back offer identifiers that a customer is eligible to redeem, which sorts the identifiers to present the better offers first. + + https://developer.apple.com/documentation/appstoreserverapi/eligiblewinbackofferids + """ + + appAccountToken: Optional[str] = attr.ib(default=None) + """ + The UUID that an app optionally generates to map a customer's in-app purchase with its resulting App Store transaction. + + https://developer.apple.com/documentation/appstoreserverapi/appaccounttoken + """ + + appTransactionId: Optional[str] = attr.ib(default=None) + """ + The unique identifier of the app download transaction. + + https://developer.apple.com/documentation/appstoreserverapi/appTransactionId + """ + + offerPeriod: Optional[str] = attr.ib(default=None) + """ + The duration of the offer. + + https://developer.apple.com/documentation/appstoreserverapi/offerPeriod + """ + + advancedCommerceInfo: Optional[AdvancedCommerceRenewalInfo] = attr.ib(default=None) + """ + Renewal information that is present only for Advanced Commerce SKUs. + + https://developer.apple.com/documentation/appstoreserverapi/advancedcommercerenewalinfo + """ + + commitmentInfo: Optional[RenewalCommitmentInfo] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/appstoreserverapi/renewalcommitmentinfo + """ + + renewalBillingPlanType: Optional[RenewalBillingPlanType] = RenewalBillingPlanType.create_main_attr('rawRenewalBillingPlanType') + """ + https://developer.apple.com/documentation/appstoreserverapi/renewalbillingplantype + """ + + rawRenewalBillingPlanType: Optional[str] = RenewalBillingPlanType.create_raw_attr('renewalBillingPlanType') + """ + See renewalBillingPlanType """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/JWSTransactionDecodedPayload.py b/appstoreserverlibrary/models/JWSTransactionDecodedPayload.py index 4b68f4cd..6ccc3e58 100644 --- a/appstoreserverlibrary/models/JWSTransactionDecodedPayload.py +++ b/appstoreserverlibrary/models/JWSTransactionDecodedPayload.py @@ -10,7 +10,11 @@ from .LibraryUtility import AttrsRawValueAware from .OfferType import OfferType from .RevocationReason import RevocationReason +from .RevocationType import RevocationType from .TransactionReason import TransactionReason +from .BillingPlanType import BillingPlanType +from .AdvancedCommerceTransactionInfo import AdvancedCommerceTransactionInfo +from .TransactionCommitmentInfo import TransactionCommitmentInfo from .Type import Type @@ -132,7 +136,7 @@ class JWSTransactionDecodedPayload(AttrsRawValueAware): revocationReason: Optional[RevocationReason] = RevocationReason.create_main_attr('rawRevocationReason') """ - The reason that the App Store refunded the transaction or revoked it from family sharing. + The reason that the App Store refunded the transaction or revoked it from Family Sharing. https://developer.apple.com/documentation/appstoreserverapi/revocationreason """ @@ -156,7 +160,7 @@ class JWSTransactionDecodedPayload(AttrsRawValueAware): https://developer.apple.com/documentation/appstoreserverapi/isupgraded """ - offerType: Optional[OfferType] = RevocationReason.create_main_attr('rawOfferType') + offerType: Optional[OfferType] = OfferType.create_main_attr('rawOfferType') """ A value that represents the promotional offer type. @@ -170,7 +174,7 @@ class JWSTransactionDecodedPayload(AttrsRawValueAware): offerIdentifier: Optional[str] = attr.ib(default=None) """ - The identifier that contains the promo code or the promotional offer identifier. + The identifier that contains the offer code or the promotional offer identifier. https://developer.apple.com/documentation/appstoreserverapi/offeridentifier """ @@ -203,7 +207,7 @@ class JWSTransactionDecodedPayload(AttrsRawValueAware): transactionReason: Optional[TransactionReason] = TransactionReason.create_main_attr('rawTransactionReason') """ - The reason for the purchase transaction, which indicates whether it's a customer's purchase or a renewal for an auto-renewable subscription that the system initates. + The reason for the purchase transaction, which indicates whether it's a customer's purchase or a renewal for an auto-renewable subscription that the system initiates. https://developer.apple.com/documentation/appstoreserverapi/transactionreason """ @@ -222,14 +226,14 @@ class JWSTransactionDecodedPayload(AttrsRawValueAware): price: Optional[int] = attr.ib(default=None) """ - The price of the in-app purchase or subscription offer that you configured in App Store Connect, as an integer. + The price, in milliunits, of the in-app purchase or subscription offer that you configured in App Store Connect. https://developer.apple.com/documentation/appstoreserverapi/price """ offerDiscountType: Optional[OfferDiscountType] = OfferDiscountType.create_main_attr('rawOfferDiscountType') """ - The payment mode you configure for an introductory offer, promotional offer, or offer code on an auto-renewable subscription. + The payment mode you configure for the offer. https://developer.apple.com/documentation/appstoreserverapi/offerdiscounttype """ @@ -237,4 +241,58 @@ class JWSTransactionDecodedPayload(AttrsRawValueAware): rawOfferDiscountType: Optional[str] = OfferDiscountType.create_raw_attr('offerDiscountType') """ See offerDiscountType - """ \ No newline at end of file + """ + + appTransactionId: Optional[str] = attr.ib(default=None) + """ + The unique identifier of the app download transaction. + + https://developer.apple.com/documentation/appstoreserverapi/appTransactionId + """ + + offerPeriod: Optional[str] = attr.ib(default=None) + """ + The duration of the offer. + + https://developer.apple.com/documentation/appstoreserverapi/offerPeriod + """ + revocationType: Optional[RevocationType] = RevocationType.create_main_attr('rawRevocationType') + """ + The type of the refund or revocation that applies to the transaction. + + https://developer.apple.com/documentation/appstoreservernotifications/revocationtype + """ + + rawRevocationType: Optional[str] = RevocationType.create_raw_attr('revocationType') + """ + See revocationType + """ + + revocationPercentage: Optional[int] = attr.ib(default=None) + """ + The percentage, in milliunits, of the transaction that the App Store has refunded or revoked. + + https://developer.apple.com/documentation/appstoreservernotifications/revocationpercentage + """ + + advancedCommerceInfo: Optional[AdvancedCommerceTransactionInfo] = attr.ib(default=None) + """ + Transaction information that is present only for Advanced Commerce SKUs. + + https://developer.apple.com/documentation/appstoreserverapi/advancedcommercetransactioninfo + """ + + billingPlanType: Optional[BillingPlanType] = BillingPlanType.create_main_attr('rawBillingPlanType') + """ + https://developer.apple.com/documentation/appstoreserverapi/billingplantype + """ + + rawBillingPlanType: Optional[str] = BillingPlanType.create_raw_attr('billingPlanType') + """ + See billingPlanType + """ + + commitmentInfo: Optional[TransactionCommitmentInfo] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/appstoreserverapi/transactioncommitmentinfo + """ diff --git a/appstoreserverlibrary/models/LibraryUtility.py b/appstoreserverlibrary/models/LibraryUtility.py index 31add6cf..0ee535d8 100644 --- a/appstoreserverlibrary/models/LibraryUtility.py +++ b/appstoreserverlibrary/models/LibraryUtility.py @@ -1,7 +1,9 @@ # Copyright (c) 2023 Apple Inc. Licensed under MIT License. from enum import EnumMeta +from functools import lru_cache from typing import Any, List, Type, TypeVar +from uuid import UUID from attr import Attribute, has, ib, fields from cattr import override @@ -12,6 +14,7 @@ metadata_key = 'correspondingFieldName' metadata_type_key = 'typeOfField' +metadata_required_key = 'requiredField' class AppStoreServerLibraryEnumMeta(EnumMeta): def __contains__(c, val): @@ -21,21 +24,39 @@ def __contains__(c, val): return False return True - def create_main_attr(c, raw_field_name: str) -> Any: + def create_main_attr(c, raw_field_name: str, raw_required: bool = False) -> Any: def value_set(self, _: Attribute, value: c): newValue = value.value if value is not None else None + if raw_required and newValue is None: + raise ValueError(f"{raw_field_name} cannot be set to None when field is required") if newValue != getattr(self, raw_field_name): object.__setattr__(self, raw_field_name, newValue) return value return ib(default=None, on_setattr=value_set, metadata={metadata_key: raw_field_name, metadata_type_key: 'main'}) - - def create_raw_attr(c, field_name: str) -> Any: + + def create_raw_attr(c, field_name: str, required: bool = False) -> Any: def value_set(self, _: Attribute, value: str): + if required and value is None: + raise ValueError(f"raw{field_name[0].upper() + field_name[1:]} cannot be None") newValue = c(value) if value in c else None if newValue != getattr(self, field_name): object.__setattr__(self, field_name, newValue) return value - return ib(default=None, kw_only=True, on_setattr=value_set, metadata={metadata_key: field_name, metadata_type_key: 'raw'}) + + def validate_not_none(instance, attribute, value): + if value is None: + raise ValueError(f"{attribute.name} cannot be None") + + if required: + from attr import Factory + def factory(instance): + main_value = getattr(instance, field_name) + if main_value is not None: + return main_value.value + raise ValueError(f"Either {field_name} or raw{field_name[0].upper() + field_name[1:]} must be provided") + return ib(default=Factory(factory, takes_self=True), kw_only=True, on_setattr=value_set, validator=validate_not_none, metadata={metadata_key: field_name, metadata_type_key: 'raw', metadata_required_key: True}) + else: + return ib(default=None, kw_only=True, on_setattr=value_set, metadata={metadata_key: field_name, metadata_type_key: 'raw', metadata_required_key: False}) class AttrsRawValueAware: def __attrs_post_init__(self): @@ -52,18 +73,36 @@ def __attrs_post_init__(self): elif value is not None: setattr(self, field, value) - + +@lru_cache(maxsize=None) def _get_cattrs_converter(destination_class: Type[T]) -> cattrs.Converter: c = cattrs.Converter() - attributes: List[Attribute] = fields(destination_class) - cattrs_overrides = {} - for attribute in attributes: - if metadata_type_key in attribute.metadata: - matching_name: str = attribute.metadata[metadata_key] - if attribute.metadata[metadata_type_key] == 'raw': - cattrs_overrides[matching_name] = override(omit=True) - raw_field = 'raw' + matching_name[0].upper() + matching_name[1:] - cattrs_overrides[raw_field] = override(rename=matching_name) - c.register_structure_hook_factory(has, lambda cl: make_dict_structure_fn(cl, c, **cattrs_overrides)) - c.register_unstructure_hook_factory(has, lambda cl: make_dict_unstructure_fn(cl, c, **cattrs_overrides)) - return c \ No newline at end of file + + # Register UUID hooks to ensure lowercase serialization + c.register_unstructure_hook(UUID, lambda uuid: str(uuid).lower() if uuid is not None else None) + c.register_structure_hook(UUID, lambda d, _: UUID(d) if d is not None else None) + + # Need a function here because it must be a lambda based on cl, which is not always destination_class + def make_overrides(cl): + attributes: List[Attribute] = fields(cl) + cattrs_overrides = {} + # Use omit_if_default to prevent null fields from being serialized to JSON + for attribute in attributes: + if metadata_type_key in attribute.metadata: + matching_name: str = attribute.metadata[metadata_key] + if attribute.metadata[metadata_type_key] == 'raw': + cattrs_overrides[matching_name] = override(omit=True) + raw_field = 'raw' + matching_name[0].upper() + matching_name[1:] + if attribute.metadata.get(metadata_required_key, False): + cattrs_overrides[raw_field] = override(rename=matching_name) + else: + cattrs_overrides[raw_field] = override(rename=matching_name, omit_if_default=True) + elif not attribute.init and attribute.name not in cattrs_overrides: + cattrs_overrides[attribute.name] = override(omit=False) + elif attribute.default is None and attribute.name not in cattrs_overrides: + cattrs_overrides[attribute.name] = override(omit_if_default=True) + return cattrs_overrides + + c.register_structure_hook_factory(has, lambda cl: make_dict_structure_fn(cl, c, **make_overrides(cl))) + c.register_unstructure_hook_factory(has, lambda cl: make_dict_unstructure_fn(cl, c, **make_overrides(cl))) + return c diff --git a/appstoreserverlibrary/models/Message.py b/appstoreserverlibrary/models/Message.py new file mode 100644 index 00000000..b975bc66 --- /dev/null +++ b/appstoreserverlibrary/models/Message.py @@ -0,0 +1,21 @@ +# Copyright (c) 2025 Apple Inc. Licensed under MIT License. +from typing import Optional +from uuid import UUID + +from attr import define +import attr + +@define +class Message: + """ + A message identifier you provide in a real-time response to your Get Retention Message endpoint. + + https://developer.apple.com/documentation/retentionmessaging/message + """ + + messageIdentifier: Optional[UUID] = attr.ib(default=None) + """ + The identifier of the message to display to the customer. + + https://developer.apple.com/documentation/retentionmessaging/messageidentifier + """ diff --git a/appstoreserverlibrary/models/MessageState.py b/appstoreserverlibrary/models/MessageState.py new file mode 100644 index 00000000..bec03ed1 --- /dev/null +++ b/appstoreserverlibrary/models/MessageState.py @@ -0,0 +1,15 @@ +# Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +from enum import Enum + +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class MessageState(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): + """ + The approval state of the message. + + https://developer.apple.com/documentation/retentionmessaging/messagestate + """ + PENDING = "PENDING" + APPROVED = "APPROVED" + REJECTED = "REJECTED" diff --git a/appstoreserverlibrary/models/NotificationHistoryResponse.py b/appstoreserverlibrary/models/NotificationHistoryResponse.py index 5538d9f1..4a4ef20f 100644 --- a/appstoreserverlibrary/models/NotificationHistoryResponse.py +++ b/appstoreserverlibrary/models/NotificationHistoryResponse.py @@ -32,4 +32,5 @@ class NotificationHistoryResponse: """ An array of App Store server notification history records. + https://developer.apple.com/documentation/appstoreserverapi/notificationhistoryresponseitem """ diff --git a/appstoreserverlibrary/models/NotificationTypeV2.py b/appstoreserverlibrary/models/NotificationTypeV2.py index e667a057..e4a80b5e 100644 --- a/appstoreserverlibrary/models/NotificationTypeV2.py +++ b/appstoreserverlibrary/models/NotificationTypeV2.py @@ -6,9 +6,9 @@ class NotificationTypeV2(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): """ - A notification type value that App Store Server Notifications V2 uses. + The type that describes the in-app purchase or external purchase event for which the App Store sends the version 2 notification. - https://developer.apple.com/documentation/appstoreserverapi/notificationtype + https://developer.apple.com/documentation/appstoreservernotifications/notificationtype """ SUBSCRIBED = "SUBSCRIBED" DID_CHANGE_RENEWAL_PREF = "DID_CHANGE_RENEWAL_PREF" @@ -27,3 +27,9 @@ class NotificationTypeV2(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): TEST = "TEST" RENEWAL_EXTENSION = "RENEWAL_EXTENSION" REFUND_REVERSED = "REFUND_REVERSED" + EXTERNAL_PURCHASE_TOKEN = "EXTERNAL_PURCHASE_TOKEN" + ONE_TIME_CHARGE = "ONE_TIME_CHARGE" + RESCIND_CONSENT = "RESCIND_CONSENT" + METADATA_UPDATE = "METADATA_UPDATE" + MIGRATION = "MIGRATION" + PRICE_CHANGE = "PRICE_CHANGE" diff --git a/appstoreserverlibrary/models/OfferDiscountType.py b/appstoreserverlibrary/models/OfferDiscountType.py index ea7cff5d..a383ca94 100644 --- a/appstoreserverlibrary/models/OfferDiscountType.py +++ b/appstoreserverlibrary/models/OfferDiscountType.py @@ -6,10 +6,11 @@ class OfferDiscountType(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): """ - The payment mode you configure for an introductory offer, promotional offer, or offer code on an auto-renewable subscription. + The payment mode for a discount offer on an In-App Purchase. https://developer.apple.com/documentation/appstoreserverapi/offerdiscounttype """ FREE_TRIAL = "FREE_TRIAL" PAY_AS_YOU_GO = "PAY_AS_YOU_GO" - PAY_UP_FRONT = "PAY_UP_FRONT" \ No newline at end of file + PAY_UP_FRONT = "PAY_UP_FRONT" + ONE_TIME = "ONE_TIME" \ No newline at end of file diff --git a/appstoreserverlibrary/models/OfferType.py b/appstoreserverlibrary/models/OfferType.py index f23799c0..48a5df66 100644 --- a/appstoreserverlibrary/models/OfferType.py +++ b/appstoreserverlibrary/models/OfferType.py @@ -6,10 +6,11 @@ class OfferType(IntEnum, metaclass=AppStoreServerLibraryEnumMeta): """ - The type of subscription offer. + The type of offer. https://developer.apple.com/documentation/appstoreserverapi/offertype """ INTRODUCTORY_OFFER = 1 PROMOTIONAL_OFFER = 2 - SUBSCRIPTION_OFFER_CODE = 3 + OFFER_CODE = 3 + WIN_BACK_OFFER = 4 diff --git a/appstoreserverlibrary/models/OrderLookupResponse.py b/appstoreserverlibrary/models/OrderLookupResponse.py index faaf42d6..deaf9851 100644 --- a/appstoreserverlibrary/models/OrderLookupResponse.py +++ b/appstoreserverlibrary/models/OrderLookupResponse.py @@ -30,4 +30,6 @@ class OrderLookupResponse(AttrsRawValueAware): signedTransactions: Optional[List[str]] = attr.ib(default=None) """ An array of in-app purchase transactions that are part of order, signed by Apple, in JSON Web Signature format. + + https://developer.apple.com/documentation/appstoreserverapi/jwstransaction """ diff --git a/appstoreserverlibrary/models/PerformanceTestConfig.py b/appstoreserverlibrary/models/PerformanceTestConfig.py new file mode 100644 index 00000000..a0f35050 --- /dev/null +++ b/appstoreserverlibrary/models/PerformanceTestConfig.py @@ -0,0 +1,49 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from typing import Optional + +from attr import define +import attr + +@define +class PerformanceTestConfig: + """ + An object that enumerates the test configuration parameters. + + https://developer.apple.com/documentation/retentionmessaging/performancetestconfig + """ + + maxConcurrentRequests: Optional[int] = attr.ib(default=None) + """ + The maximum number of concurrent requests the API allows. + + https://developer.apple.com/documentation/retentionmessaging/maxconcurrentrequests + """ + + totalRequests: Optional[int] = attr.ib(default=None) + """ + The total number of requests to make during the test. + + https://developer.apple.com/documentation/retentionmessaging/totalrequests + """ + + totalDuration: Optional[int] = attr.ib(default=None) + """ + The total duration of the test in milliseconds. + + https://developer.apple.com/documentation/retentionmessaging/totalduration + """ + + responseTimeThreshold: Optional[int] = attr.ib(default=None) + """ + The maximum time your server has to respond when the system calls your Get Retention Message endpoint in the sandbox environment. + + https://developer.apple.com/documentation/retentionmessaging/responsetimethreshold + """ + + successRateThreshold: Optional[int] = attr.ib(default=None) + """ + The success rate threshold percentage. + + https://developer.apple.com/documentation/retentionmessaging/successratethreshold + """ diff --git a/appstoreserverlibrary/models/PerformanceTestRequest.py b/appstoreserverlibrary/models/PerformanceTestRequest.py new file mode 100644 index 00000000..66768bcd --- /dev/null +++ b/appstoreserverlibrary/models/PerformanceTestRequest.py @@ -0,0 +1,19 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from attr import define +import attr + +@define +class PerformanceTestRequest: + """ + The request object you provide for a performance test that contains an original transaction identifier. + + https://developer.apple.com/documentation/retentionmessaging/performancetestrequest + """ + + originalTransactionId: str = attr.ib() + """ + The original transaction identifier of an In-App Purchase you initiate in the sandbox environment, to use as the purchase for this test. + + https://developer.apple.com/documentation/retentionmessaging/originaltransactionid + """ diff --git a/appstoreserverlibrary/models/PerformanceTestResponse.py b/appstoreserverlibrary/models/PerformanceTestResponse.py new file mode 100644 index 00000000..e5e81915 --- /dev/null +++ b/appstoreserverlibrary/models/PerformanceTestResponse.py @@ -0,0 +1,30 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from typing import Optional + +from attr import define +import attr + +from .PerformanceTestConfig import PerformanceTestConfig + +@define +class PerformanceTestResponse: + """ + The performance test response object. + + https://developer.apple.com/documentation/retentionmessaging/performancetestresponse + """ + + config: Optional[PerformanceTestConfig] = attr.ib(default=None) + """ + The performance test configuration object. + + https://developer.apple.com/documentation/retentionmessaging/performancetestconfig + """ + + requestId: Optional[str] = attr.ib(default=None) + """ + The performance test request identifier. + + https://developer.apple.com/documentation/retentionmessaging/requestid + """ diff --git a/appstoreserverlibrary/models/PerformanceTestResponseTimes.py b/appstoreserverlibrary/models/PerformanceTestResponseTimes.py new file mode 100644 index 00000000..b8d4fe5e --- /dev/null +++ b/appstoreserverlibrary/models/PerformanceTestResponseTimes.py @@ -0,0 +1,49 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from typing import Optional + +from attr import define +import attr + +@define +class PerformanceTestResponseTimes: + """ + An object that describes test response times. + + https://developer.apple.com/documentation/retentionmessaging/performancetestresponsetimes + """ + + average: Optional[int] = attr.ib(default=None) + """ + Average response time in milliseconds. + + https://developer.apple.com/documentation/retentionmessaging/average + """ + + p50: Optional[int] = attr.ib(default=None) + """ + The 50th percentile response time in milliseconds. + + https://developer.apple.com/documentation/retentionmessaging/p50 + """ + + p90: Optional[int] = attr.ib(default=None) + """ + The 90th percentile response time in milliseconds. + + https://developer.apple.com/documentation/retentionmessaging/p90 + """ + + p95: Optional[int] = attr.ib(default=None) + """ + The 95th percentile response time in milliseconds. + + https://developer.apple.com/documentation/retentionmessaging/p95 + """ + + p99: Optional[int] = attr.ib(default=None) + """ + The 99th percentile response time in milliseconds. + + https://developer.apple.com/documentation/retentionmessaging/p99 + """ diff --git a/appstoreserverlibrary/models/PerformanceTestResultResponse.py b/appstoreserverlibrary/models/PerformanceTestResultResponse.py new file mode 100644 index 00000000..bd246b8d --- /dev/null +++ b/appstoreserverlibrary/models/PerformanceTestResultResponse.py @@ -0,0 +1,96 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from typing import Dict, Optional + +from attr import define, Attribute +import attr + +from .LibraryUtility import AttrsRawValueAware, metadata_key, metadata_type_key +from .PerformanceTestConfig import PerformanceTestConfig +from .PerformanceTestResponseTimes import PerformanceTestResponseTimes +from .PerformanceTestStatus import PerformanceTestStatus +from .SendAttemptResult import SendAttemptResult + +def _failures_value_set(self, _: Attribute, value: Optional[Dict[SendAttemptResult, int]]): + new_raw = {k.value: v for k, v in value.items()} if value is not None else None + if new_raw != getattr(self, 'rawFailures'): + object.__setattr__(self, 'rawFailures', new_raw) + return value + +def _raw_failures_value_set(self, _: Attribute, value: Optional[Dict[str, int]]): + new_typed = {} + if value is not None: + for k, v in value.items(): + if k in SendAttemptResult: + new_typed[SendAttemptResult(k)] = v + new_typed = new_typed if new_typed else None + if new_typed != getattr(self, 'failures'): + object.__setattr__(self, 'failures', new_typed) + return value + +@define +class PerformanceTestResultResponse(AttrsRawValueAware): + """ + An object the API returns that describes the performance test results. + + https://developer.apple.com/documentation/retentionmessaging/performancetestresultresponse + """ + + config: Optional[PerformanceTestConfig] = attr.ib(default=None) + """ + A PerformanceTestConfig object that enumerates the test parameters. + + https://developer.apple.com/documentation/retentionmessaging/performancetestconfig + """ + + target: Optional[str] = attr.ib(default=None) + """ + The target URL for the performance test. + + https://developer.apple.com/documentation/retentionmessaging/target + """ + + result: Optional[PerformanceTestStatus] = PerformanceTestStatus.create_main_attr('rawResult') + """ + A PerformanceTestStatus object that describes the overall result of the test. + + https://developer.apple.com/documentation/retentionmessaging/performanceteststatus + """ + + rawResult: Optional[str] = PerformanceTestStatus.create_raw_attr('result') + """ + See result + """ + + successRate: Optional[int] = attr.ib(default=None) + """ + An integer that describes he success rate percentage of the performance test. + + https://developer.apple.com/documentation/retentionmessaging/successrate + """ + + numPending: Optional[int] = attr.ib(default=None) + """ + An integer that describes the number of pending requests in the performance test. + + https://developer.apple.com/documentation/retentionmessaging/numpending + """ + + responseTimes: Optional[PerformanceTestResponseTimes] = attr.ib(default=None) + """ + A PerformanceTestResponseTimes object that enumerates the response times measured during the test. + + https://developer.apple.com/documentation/retentionmessaging/performancetestresponsetimes + """ + + failures: Optional[Dict[SendAttemptResult, int]] = attr.ib(default=None, on_setattr=_failures_value_set, metadata={metadata_key: 'rawFailures', metadata_type_key: 'main'}) + """ + A map of server-to-server notification failure reasons and counts that represent the number of failures encountered during the performance test. + + https://developer.apple.com/documentation/retentionmessaging/failures + """ + + rawFailures: Optional[Dict[str, int]] = attr.ib(default=None, kw_only=True, on_setattr=_raw_failures_value_set, metadata={metadata_key: 'failures', metadata_type_key: 'raw'}) + """ + See failures + """ diff --git a/appstoreserverlibrary/models/PerformanceTestStatus.py b/appstoreserverlibrary/models/PerformanceTestStatus.py new file mode 100644 index 00000000..92c9501c --- /dev/null +++ b/appstoreserverlibrary/models/PerformanceTestStatus.py @@ -0,0 +1,15 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from enum import Enum + +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class PerformanceTestStatus(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): + """ + The status of the performance test. + + https://developer.apple.com/documentation/retentionmessaging/performanceteststatus + """ + PENDING = "PENDING" + PASS = "PASS" + FAIL = "FAIL" diff --git a/appstoreserverlibrary/models/PromotionalOffer.py b/appstoreserverlibrary/models/PromotionalOffer.py new file mode 100644 index 00000000..f980f24f --- /dev/null +++ b/appstoreserverlibrary/models/PromotionalOffer.py @@ -0,0 +1,38 @@ +# Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +from typing import Optional +from uuid import UUID + +from attr import define +import attr + +from .PromotionalOfferSignatureV1 import PromotionalOfferSignatureV1 + +@define +class PromotionalOffer: + """ + A promotional offer and message you provide in a real-time response to your Get Retention Message endpoint. + + https://developer.apple.com/documentation/retentionmessaging/promotionaloffer + """ + + messageIdentifier: Optional[UUID] = attr.ib(default=None) + """ + The identifier of the message to display to the customer, along with the promotional offer. + + https://developer.apple.com/documentation/retentionmessaging/messageidentifier + """ + + promotionalOfferSignatureV2: Optional[str] = attr.ib(default=None) + """ + The promotional offer signature in V2 format. + + https://developer.apple.com/documentation/retentionmessaging/promotionaloffersignaturev2 + """ + + promotionalOfferSignatureV1: Optional[PromotionalOfferSignatureV1] = attr.ib(default=None) + """ + The promotional offer signature in V1 format. + + https://developer.apple.com/documentation/retentionmessaging/promotionaloffersignaturev1 + """ diff --git a/appstoreserverlibrary/models/PromotionalOfferSignatureV1.py b/appstoreserverlibrary/models/PromotionalOfferSignatureV1.py new file mode 100644 index 00000000..54a15867 --- /dev/null +++ b/appstoreserverlibrary/models/PromotionalOfferSignatureV1.py @@ -0,0 +1,52 @@ +# Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +from typing import Optional +from uuid import UUID + +from attr import define +import attr + +@define +class PromotionalOfferSignatureV1: + """ + The promotional offer signature you generate using an earlier signature version. + + https://developer.apple.com/documentation/retentionmessaging/promotionaloffersignaturev1 + """ + + encodedSignature: str = attr.ib() + """ + The Base64-encoded cryptographic signature you generate using the offer parameters. + """ + + productId: str = attr.ib() + """ + The subscription's product identifier. + + https://developer.apple.com/documentation/retentionmessaging/productid + """ + + nonce: UUID = attr.ib() + """ + A one-time-use UUID antireplay value you generate. + """ + + timestamp: int = attr.ib() + """ + The UNIX time, in milliseconds, when you generate the signature. + """ + + keyId: str = attr.ib() + """ + A string that identifies the private key you use to generate the signature. + """ + + offerIdentifier: str = attr.ib() + """ + The subscription offer identifier that you set up in App Store Connect. + """ + + appAccountToken: Optional[UUID] = attr.ib(default=None) + """ + A UUID that you provide to associate with the transaction if the customer accepts the promotional offer. + """ diff --git a/appstoreserverlibrary/models/PurchasePlatform.py b/appstoreserverlibrary/models/PurchasePlatform.py new file mode 100644 index 00000000..9549c040 --- /dev/null +++ b/appstoreserverlibrary/models/PurchasePlatform.py @@ -0,0 +1,16 @@ +# Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +from enum import Enum + +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class PurchasePlatform(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): + """ + Values that represent Apple platforms. + + https://developer.apple.com/documentation/storekit/appstore/platform + """ + IOS = "iOS" + MAC_OS = "macOS" + TV_OS = "tvOS" + VISION_OS = "visionOS" diff --git a/appstoreserverlibrary/models/RealtimeRequestBody.py b/appstoreserverlibrary/models/RealtimeRequestBody.py new file mode 100644 index 00000000..67f18047 --- /dev/null +++ b/appstoreserverlibrary/models/RealtimeRequestBody.py @@ -0,0 +1,21 @@ +# Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +from typing import Optional, Dict + +from attr import define +import attr + +@define +class RealtimeRequestBody: + """ + The request body the App Store server sends to your Get Retention Message endpoint. + + https://developer.apple.com/documentation/retentionmessaging/realtimerequestbody + """ + + signedPayload: Optional[str] = attr.ib(default=None) + """ + The payload in JSON Web Signature (JWS) format, signed by the App Store. + + https://developer.apple.com/documentation/retentionmessaging/signedpayload + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/RealtimeResponseBody.py b/appstoreserverlibrary/models/RealtimeResponseBody.py new file mode 100644 index 00000000..15420e20 --- /dev/null +++ b/appstoreserverlibrary/models/RealtimeResponseBody.py @@ -0,0 +1,47 @@ +# Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +from typing import Optional + +from attr import define +import attr + +from .Message import Message +from .AlternateProduct import AlternateProduct +from .PromotionalOffer import PromotionalOffer +from .AdvancedCommerceInfo import AdvancedCommerceInfo + +@define +class RealtimeResponseBody: + """ + A response you provide to choose, in real time, a retention message the system displays to the customer. + + https://developer.apple.com/documentation/retentionmessaging/realtimeresponsebody + """ + + message: Optional[Message] = attr.ib(default=None) + """ + A retention message that's text-based and can include an optional image. + + https://developer.apple.com/documentation/retentionmessaging/message + """ + + alternateProduct: Optional[AlternateProduct] = attr.ib(default=None) + """ + A retention message with a switch-plan option. + + https://developer.apple.com/documentation/retentionmessaging/alternateproduct + """ + + promotionalOffer: Optional[PromotionalOffer] = attr.ib(default=None) + """ + A retention message that includes a promotional offer. + + https://developer.apple.com/documentation/retentionmessaging/promotionaloffer + """ + + advancedCommerceInfo: Optional[AdvancedCommerceInfo] = attr.ib(default=None) + """ + A retention offer or switch plan option. + + https://developer.apple.com/documentation/retentionmessaging/advancedcommerceinfo + """ diff --git a/appstoreserverlibrary/models/RealtimeUrlRequest.py b/appstoreserverlibrary/models/RealtimeUrlRequest.py new file mode 100644 index 00000000..36a64463 --- /dev/null +++ b/appstoreserverlibrary/models/RealtimeUrlRequest.py @@ -0,0 +1,19 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from attr import define +import attr + +@define +class RealtimeUrlRequest: + """ + The request body for configuring the URL of your Get Retention Message endpoint. + + https://developer.apple.com/documentation/retentionmessaging/realtimeurlrequest + """ + + realtimeURL: str = attr.ib(validator=attr.validators.max_len(256)) + """ + A string that contains the URL of your Get Retention Message endpoint for configuration. + + https://developer.apple.com/documentation/retentionmessaging/realtimeurl + """ diff --git a/appstoreserverlibrary/models/RealtimeUrlResponse.py b/appstoreserverlibrary/models/RealtimeUrlResponse.py new file mode 100644 index 00000000..5cd75f07 --- /dev/null +++ b/appstoreserverlibrary/models/RealtimeUrlResponse.py @@ -0,0 +1,21 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from typing import Optional + +from attr import define +import attr + +@define +class RealtimeUrlResponse: + """ + The response body that contains the URL for your Get Retention Message endpoint. + + https://developer.apple.com/documentation/retentionmessaging/realtimeurlresponse + """ + + realtimeURL: Optional[str] = attr.ib() + """ + A string that contains the URL you provided for your Get Retention Message endpoint. + + https://developer.apple.com/documentation/retentionmessaging/realtimeurl + """ diff --git a/appstoreserverlibrary/models/RefundHistoryResponse.py b/appstoreserverlibrary/models/RefundHistoryResponse.py index f5b88bcb..97b15d2e 100644 --- a/appstoreserverlibrary/models/RefundHistoryResponse.py +++ b/appstoreserverlibrary/models/RefundHistoryResponse.py @@ -15,6 +15,8 @@ class RefundHistoryResponse: signedTransactions: Optional[List[str]] = attr.ib(default=None) """ A list of up to 20 JWS transactions, or an empty array if the customer hasn't received any refunds in your app. The transactions are sorted in ascending order by revocationDate. + + https://developer.apple.com/documentation/appstoreserverapi/jwstransaction """ revision: Optional[str] = attr.ib(default=None) diff --git a/appstoreserverlibrary/models/RefundPreference.py b/appstoreserverlibrary/models/RefundPreference.py new file mode 100644 index 00000000..c20664e6 --- /dev/null +++ b/appstoreserverlibrary/models/RefundPreference.py @@ -0,0 +1,15 @@ +# Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +from enum import Enum + +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class RefundPreference(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): + """ + A value that indicates your preference, based on your operational logic, as to whether Apple should grant the refund. + + https://developer.apple.com/documentation/appstoreserverapi/refundpreference + """ + DECLINE = "DECLINE" + GRANT_FULL = "GRANT_FULL" + GRANT_PRORATED = "GRANT_PRORATED" diff --git a/appstoreserverlibrary/models/RefundPreferenceV1.py b/appstoreserverlibrary/models/RefundPreferenceV1.py new file mode 100644 index 00000000..80737e5d --- /dev/null +++ b/appstoreserverlibrary/models/RefundPreferenceV1.py @@ -0,0 +1,19 @@ +# Copyright (c) 2024 Apple Inc. Licensed under MIT License. + +from enum import IntEnum + +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class RefundPreferenceV1(IntEnum, metaclass=AppStoreServerLibraryEnumMeta): + """ + A value that indicates your preferred outcome for the refund request. + + .. deprecated:: + Use :class:`RefundPreference` instead. + + https://developer.apple.com/documentation/appstoreserverapi/refundpreferencev1 + """ + UNDECLARED = 0 + PREFER_GRANT = 1 + PREFER_DECLINE = 2 + NO_PREFERENCE = 3 diff --git a/appstoreserverlibrary/models/RenewalBillingPlanType.py b/appstoreserverlibrary/models/RenewalBillingPlanType.py new file mode 100644 index 00000000..8c955ae5 --- /dev/null +++ b/appstoreserverlibrary/models/RenewalBillingPlanType.py @@ -0,0 +1,12 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from enum import Enum + +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class RenewalBillingPlanType(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): + """ + https://developer.apple.com/documentation/appstoreserverapi/renewalbillingplantype + """ + BILLED_UPFRONT = "BILLED_UPFRONT" + MONTHLY = "MONTHLY" diff --git a/appstoreserverlibrary/models/RenewalCommitmentInfo.py b/appstoreserverlibrary/models/RenewalCommitmentInfo.py new file mode 100644 index 00000000..cc830de1 --- /dev/null +++ b/appstoreserverlibrary/models/RenewalCommitmentInfo.py @@ -0,0 +1,51 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from typing import Optional + +from attr import define +import attr + +from .AutoRenewStatus import AutoRenewStatus +from .RenewalBillingPlanType import RenewalBillingPlanType +from .LibraryUtility import AttrsRawValueAware + +@define +class RenewalCommitmentInfo(AttrsRawValueAware): + """ + https://developer.apple.com/documentation/appstoreserverapi/renewalcommitmentinfo + """ + + commitmentAutoRenewProductId: Optional[str] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/appstoreserverapi/commitmentautorenewproductid + """ + + commitmentAutoRenewStatus: Optional[AutoRenewStatus] = AutoRenewStatus.create_main_attr('rawCommitmentAutoRenewStatus') + """ + https://developer.apple.com/documentation/appstoreserverapi/commitmentautorenewstatus + """ + + rawCommitmentAutoRenewStatus: Optional[int] = AutoRenewStatus.create_raw_attr('commitmentAutoRenewStatus') + """ + See commitmentAutoRenewStatus + """ + + commitmentRenewalBillingPlanType: Optional[RenewalBillingPlanType] = RenewalBillingPlanType.create_main_attr('rawCommitmentRenewalBillingPlanType') + """ + https://developer.apple.com/documentation/appstoreserverapi/commitmentrenewalbillingplantype + """ + + rawCommitmentRenewalBillingPlanType: Optional[str] = RenewalBillingPlanType.create_raw_attr('commitmentRenewalBillingPlanType') + """ + See commitmentRenewalBillingPlanType + """ + + commitmentRenewalDate: Optional[int] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/appstoreserverapi/commitmentrenewaldate + """ + + commitmentRenewalPrice: Optional[int] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/appstoreserverapi/commitmentrenewalprice + """ diff --git a/appstoreserverlibrary/models/ResponseBodyV2DecodedPayload.py b/appstoreserverlibrary/models/ResponseBodyV2DecodedPayload.py index f9400c71..855e89ab 100644 --- a/appstoreserverlibrary/models/ResponseBodyV2DecodedPayload.py +++ b/appstoreserverlibrary/models/ResponseBodyV2DecodedPayload.py @@ -3,8 +3,10 @@ from attr import define import attr -from .Data import Data +from .AppData import AppData +from .Data import Data +from .ExternalPurchaseToken import ExternalPurchaseToken from .LibraryUtility import AttrsRawValueAware from .NotificationTypeV2 import NotificationTypeV2 from .Subtype import Subtype @@ -53,7 +55,7 @@ class ResponseBodyV2DecodedPayload(AttrsRawValueAware): data: Optional[Data] = attr.ib(default=None) """ The object that contains the app metadata and signed renewal and transaction information. - The data and summary fields are mutually exclusive. The payload contains one of the fields, but not both. + The data, summary, and externalPurchaseToken fields are mutually exclusive. The payload contains only one of these fields. https://developer.apple.com/documentation/appstoreservernotifications/data """ @@ -75,7 +77,21 @@ class ResponseBodyV2DecodedPayload(AttrsRawValueAware): summary: Optional[Summary] = attr.ib(default=None) """ The summary data that appears when the App Store server completes your request to extend a subscription renewal date for eligible subscribers. - The data and summary fields are mutually exclusive. The payload contains one of the fields, but not both. + The data, summary, and externalPurchaseToken fields are mutually exclusive. The payload contains only one of these fields. https://developer.apple.com/documentation/appstoreservernotifications/summary - """ \ No newline at end of file + """ + + externalPurchaseToken: Optional[ExternalPurchaseToken] = attr.ib(default=None) + """ + This field appears when the notificationType is EXTERNAL_PURCHASE_TOKEN. + The data, summary, and externalPurchaseToken fields are mutually exclusive. The payload contains only one of these fields. + + https://developer.apple.com/documentation/appstoreservernotifications/externalpurchasetoken + """ + appData: Optional[AppData] = attr.ib(default=None) + """ + The object that contains the app metadata and signed app transaction information. This field appears when the notificationType is RESCIND_CONSENT. + + https://developer.apple.com/documentation/appstoreservernotifications/appdata + """ diff --git a/appstoreserverlibrary/models/RevocationType.py b/appstoreserverlibrary/models/RevocationType.py new file mode 100644 index 00000000..d48f287b --- /dev/null +++ b/appstoreserverlibrary/models/RevocationType.py @@ -0,0 +1,15 @@ +# Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +from enum import Enum + +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class RevocationType(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): + """ + The type of the refund or revocation that applies to the transaction. + + https://developer.apple.com/documentation/appstoreservernotifications/revocationtype + """ + REFUND_FULL = "REFUND_FULL" + REFUND_PRORATED = "REFUND_PRORATED" + FAMILY_REVOKE = "FAMILY_REVOKE" diff --git a/appstoreserverlibrary/models/StatusResponse.py b/appstoreserverlibrary/models/StatusResponse.py index 8b2fb0f2..3492f14b 100644 --- a/appstoreserverlibrary/models/StatusResponse.py +++ b/appstoreserverlibrary/models/StatusResponse.py @@ -46,4 +46,5 @@ class StatusResponse(AttrsRawValueAware): """ An array of information for auto-renewable subscriptions, including App Store-signed transaction information and App Store-signed renewal information. + https://developer.apple.com/documentation/appstoreserverapi/subscriptiongroupidentifieritem """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/SubscriptionGroupIdentifierItem.py b/appstoreserverlibrary/models/SubscriptionGroupIdentifierItem.py index 6813eb62..a95f5c75 100644 --- a/appstoreserverlibrary/models/SubscriptionGroupIdentifierItem.py +++ b/appstoreserverlibrary/models/SubscriptionGroupIdentifierItem.py @@ -24,4 +24,6 @@ class SubscriptionGroupIdentifierItem: lastTransactions: Optional[List[LastTransactionsItem]] = attr.ib(default=None) """ An array of the most recent App Store-signed transaction information and App Store-signed renewal information for all auto-renewable subscriptions in the subscription group. + + https://developer.apple.com/documentation/appstoreserverapi/lasttransactionsitem """ diff --git a/appstoreserverlibrary/models/Subtype.py b/appstoreserverlibrary/models/Subtype.py index c816764c..d1736833 100644 --- a/appstoreserverlibrary/models/Subtype.py +++ b/appstoreserverlibrary/models/Subtype.py @@ -6,9 +6,9 @@ class Subtype(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): """ - A notification subtype value that App Store Server Notifications 2 uses. + A string that provides details about select notification types in version 2. - https://developer.apple.com/documentation/appstoreserverapi/notificationsubtype + https://developer.apple.com/documentation/appstoreservernotifications/subtype """ INITIAL_BUY = "INITIAL_BUY" RESUBSCRIBE = "RESUBSCRIBE" @@ -26,3 +26,4 @@ class Subtype(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): PRODUCT_NOT_FOR_SALE = "PRODUCT_NOT_FOR_SALE" SUMMARY = "SUMMARY" FAILURE = "FAILURE" + UNREPORTED = "UNREPORTED" diff --git a/appstoreserverlibrary/models/TransactionCommitmentInfo.py b/appstoreserverlibrary/models/TransactionCommitmentInfo.py new file mode 100644 index 00000000..13bd9a0b --- /dev/null +++ b/appstoreserverlibrary/models/TransactionCommitmentInfo.py @@ -0,0 +1,34 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from typing import Optional + +from attr import define +import attr + +from .HelperValidationUtils import HelperValidationUtils + +@define +class TransactionCommitmentInfo: + """ + https://developer.apple.com/documentation/appstoreserverapi/transactioncommitmentinfo + """ + + billingPeriodNumber: Optional[int] = attr.ib(default=None, validator=attr.validators.optional(HelperValidationUtils.period_count_validator)) + """ + https://developer.apple.com/documentation/appstoreserverapi/billingperiodnumber + """ + + commitmentExpiresDate: Optional[int] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/appstoreserverapi/commitmentexpiresdate + """ + + commitmentPrice: Optional[int] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/appstoreserverapi/commitmentprice + """ + + totalBillingPeriods: Optional[int] = attr.ib(default=None) + """ + https://developer.apple.com/documentation/appstoreserverapi/totalbillingperiods + """ diff --git a/appstoreserverlibrary/models/UpdateAppAccountTokenRequest.py b/appstoreserverlibrary/models/UpdateAppAccountTokenRequest.py new file mode 100644 index 00000000..b3e8163a --- /dev/null +++ b/appstoreserverlibrary/models/UpdateAppAccountTokenRequest.py @@ -0,0 +1,18 @@ +# Copyright (c) 2025 Apple Inc. Licensed under MIT License. +from attr import define +import attr + +@define +class UpdateAppAccountTokenRequest: + """ + The request body that contains an app account token value. + + https://developer.apple.com/documentation/appstoreserverapi/updateappaccounttokenrequest + """ + + appAccountToken: str = attr.ib() + """ + The UUID that an app optionally generates to map a customer's in-app purchase with its resulting App Store transaction. + + https://developer.apple.com/documentation/appstoreserverapi/appaccounttoken + """ diff --git a/appstoreserverlibrary/models/UploadMessageImage.py b/appstoreserverlibrary/models/UploadMessageImage.py new file mode 100644 index 00000000..8f828e17 --- /dev/null +++ b/appstoreserverlibrary/models/UploadMessageImage.py @@ -0,0 +1,28 @@ +# Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +from uuid import UUID + +from attr import define +import attr + +@define +class UploadMessageImage: + """ + The definition of an image with its alternative text. + + https://developer.apple.com/documentation/retentionmessaging/uploadmessageimage + """ + + imageIdentifier: UUID = attr.ib() + """ + The unique identifier of an image. + + https://developer.apple.com/documentation/retentionmessaging/imageidentifier + """ + + altText: str = attr.ib(validator=attr.validators.max_len(150)) + """ + The alternative text you provide for the corresponding image. + + https://developer.apple.com/documentation/retentionmessaging/alttext + """ diff --git a/appstoreserverlibrary/models/UploadMessageRequestBody.py b/appstoreserverlibrary/models/UploadMessageRequestBody.py new file mode 100644 index 00000000..2cc17f67 --- /dev/null +++ b/appstoreserverlibrary/models/UploadMessageRequestBody.py @@ -0,0 +1,59 @@ +# Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +from typing import List, Optional + +from attr import define +import attr + +from .BulletPoint import BulletPoint +from .HeaderPosition import HeaderPosition +from .LibraryUtility import AttrsRawValueAware +from .UploadMessageImage import UploadMessageImage + +@define +class UploadMessageRequestBody(AttrsRawValueAware): + """ + The request body for uploading a message, which includes the message text and an optional image reference. + + https://developer.apple.com/documentation/retentionmessaging/uploadmessagerequestbody + """ + + header: str = attr.ib(validator=attr.validators.max_len(66)) + """ + The header text of the retention message that the system displays to customers. + + https://developer.apple.com/documentation/retentionmessaging/header + """ + + body: str = attr.ib(validator=attr.validators.max_len(144)) + """ + The body text of the retention message that the system displays to customers. + + https://developer.apple.com/documentation/retentionmessaging/body + """ + + image: Optional[UploadMessageImage] = attr.ib(default=None) + """ + The optional image identifier and its alternative text to appear as part of a text-based message with an image. + + https://developer.apple.com/documentation/retentionmessaging/uploadmessageimage + """ + + headerPosition: Optional[HeaderPosition] = HeaderPosition.create_main_attr('rawHeaderPosition') + """ + The position of header text, which defaults to placing header text above the body. + + https://developer.apple.com/documentation/retentionmessaging/headerposition + """ + + rawHeaderPosition: Optional[str] = HeaderPosition.create_raw_attr('headerPosition') + """ + See headerPosition + """ + + bulletPoints: Optional[List[BulletPoint]] = attr.ib(default=None, validator=attr.validators.optional(attr.validators.max_len(5))) + """ + An optional array of bullet points. + + https://developer.apple.com/documentation/retentionmessaging/bulletpoint + """ diff --git a/appstoreserverlibrary/receipt_utility.py b/appstoreserverlibrary/receipt_utility.py index 504005d5..be47cc30 100644 --- a/appstoreserverlibrary/receipt_utility.py +++ b/appstoreserverlibrary/receipt_utility.py @@ -10,8 +10,15 @@ PKCS7_OID = "1.2.840.113549.1.7.2" IN_APP_ARRAY = 17 TRANSACTION_IDENTIFIER = 1703 +ORIGINAL_TRANSACTION_IDENTIFIER = 1705 class ReceiptUtility: + def _decode_octet_string(self, octet_string: bytes): + decoder = asn1.Decoder() + decoder.start(octet_string) + _, value = decoder.read() + return value + def extract_transaction_id_from_app_receipt(self, app_receipt: str) -> Optional[str]: """ Extracts a transaction id from an encoded App Receipt. Throws if the receipt does not match the expected format. @@ -20,68 +27,25 @@ def extract_transaction_id_from_app_receipt(self, app_receipt: str) -> Optional[ :param appReceipt: The unmodified app receipt :return: A transaction id from the array of in-app purchases, null if the receipt contains no in-app purchases """ - decoder = IndefiniteFormAwareDecoder() - decoder.start(b64decode(app_receipt, validate=True)) - tag = decoder.peek() - if tag.typ != asn1.Types.Constructed or tag.nr != asn1.Numbers.Sequence: - raise ValueError() - decoder.enter() - # PKCS#7 object - tag, value = decoder.read() - if tag.typ != asn1.Types.Primitive or tag.nr != asn1.Numbers.ObjectIdentifier or value != PKCS7_OID: - raise ValueError() - # This is the PKCS#7 format, work our way into the inner content - decoder.enter() - decoder.enter() - decoder.read() - decoder.read() - decoder.enter() - decoder.read() - decoder.enter() - tag, value = decoder.read() - # Xcode uses nested OctetStrings, we extract the inner string in this case - if tag.typ == asn1.Types.Constructed and tag.nr == asn1.Numbers.OctetString: - inner_decoder = asn1.Decoder() - inner_decoder.start(value) - tag, value = inner_decoder.read() - if tag.typ != asn1.Types.Primitive or tag.nr != asn1.Numbers.OctetString: - raise ValueError() - decoder = asn1.Decoder() - decoder.start(value) - tag = decoder.peek() - if tag.typ != asn1.Types.Constructed or tag.nr != asn1.Numbers.Set: - raise ValueError() - decoder.enter() - # We are in the top-level sequence, work our way to the array of in-apps - while not decoder.eof(): - decoder.enter() - tag, value = decoder.read() - if tag.typ == asn1.Types.Primitive and tag.nr == asn1.Numbers.Integer and value == IN_APP_ARRAY: - decoder.read() - tag, value = decoder.read() - if tag.typ != asn1.Types.Primitive or tag.nr != asn1.Numbers.OctetString: - raise ValueError() - inapp_decoder = asn1.Decoder() - inapp_decoder.start(value) - inapp_decoder.enter() - # In-app array - while not inapp_decoder.eof(): - inapp_decoder.enter() - tag, value = inapp_decoder.read() - if ( - tag.typ == asn1.Types.Primitive - and tag.nr == asn1.Numbers.Integer - and value == TRANSACTION_IDENTIFIER - ): - inapp_decoder.read() - tag, value = inapp_decoder.read() - singleton_decoder = asn1.Decoder() - singleton_decoder.start(value) - tag, value = singleton_decoder.read() - return value - inapp_decoder.leave() - decoder.leave() - return None + try: + val = self._decode_octet_string(b64decode(app_receipt, validate=True)) + found_oid = val[0] + if found_oid != PKCS7_OID: + raise ValueError() + inner_value = val[1][0][2][1][0] + # Xcode uses nested OctetStrings, we extract the inner string in this case + value = self._decode_octet_string(inner_value) + # We are in the top-level sequence, work our way to the array of in-apps + for inner_value in value: + if inner_value[0] == IN_APP_ARRAY: + array_values = self._decode_octet_string(inner_value[2]) + # In-app array + for array_value in array_values: + if array_value[0] == TRANSACTION_IDENTIFIER or array_value[0] == ORIGINAL_TRANSACTION_IDENTIFIER: + return self._decode_octet_string(array_value[2]) + return None + except Exception as e: + raise ValueError(e) def extract_transaction_id_from_transaction_receipt(self, transaction_receipt: str) -> Optional[str]: """ @@ -91,26 +55,10 @@ def extract_transaction_id_from_transaction_receipt(self, transaction_receipt: s :return: A transaction id, or null if no transactionId is found in the receipt """ decoded_top_level = base64.b64decode(transaction_receipt).decode('utf-8') - matching_result = re.search('"purchase-info"\s+=\s+"([a-zA-Z0-9+/=]+)";', decoded_top_level) + matching_result = re.search(r'"purchase-info"\s+=\s+"([a-zA-Z0-9+/=]+)";', decoded_top_level) if matching_result: decoded_inner_level = base64.b64decode(matching_result.group(1)).decode('utf-8') - inner_matching_result = re.search('"transaction-id"\s+=\s+"([a-zA-Z0-9+/=]+)";', decoded_inner_level) + inner_matching_result = re.search(r'"transaction-id"\s+=\s+"([a-zA-Z0-9+/=]+)";', decoded_inner_level) if inner_matching_result: return inner_matching_result.group(1) return None - -class IndefiniteFormAwareDecoder(asn1.Decoder): - def _read_length(self) -> int: - index, input_data = self.m_stack[-1] - try: - byte = input_data[index] - except IndexError: - raise asn1.Error('Premature end of input.') - if byte == 0x80: - # Xcode receipts use indefinite length encoding, not supported by all parsers - # Indefinite length encoding is only entered, but never left during parsing for receipts - # We therefore round up indefinite length encoding to be the remaining length - self._read_byte() - index, input_data = self.m_stack[-1] - return len(input_data) - index - return super()._read_length() \ No newline at end of file diff --git a/appstoreserverlibrary/signed_data_verifier.py b/appstoreserverlibrary/signed_data_verifier.py index cf22e3c4..944fee55 100644 --- a/appstoreserverlibrary/signed_data_verifier.py +++ b/appstoreserverlibrary/signed_data_verifier.py @@ -1,6 +1,6 @@ # Copyright (c) 2023 Apple Inc. Licensed under MIT License. -from typing import List +from typing import List, Optional, Dict from base64 import b64decode from enum import IntEnum import time @@ -19,6 +19,7 @@ from appstoreserverlibrary.models.AppTransaction import AppTransaction from appstoreserverlibrary.models.LibraryUtility import _get_cattrs_converter +from .models.DecodedRealtimeRequestBody import DecodedRealtimeRequestBody from .models.Environment import Environment from .models.ResponseBodyV2DecodedPayload import ResponseBodyV2DecodedPayload from .models.JWSTransactionDecodedPayload import JWSTransactionDecodedPayload @@ -34,17 +35,20 @@ def __init__( enable_online_checks: bool, environment: Environment, bundle_id: str, - app_apple_id: int = None, + app_apple_id: Optional[int] = None, ): self._chain_verifier = _ChainVerifier(root_certificates) self._environment = environment self._bundle_id = bundle_id self._app_apple_id = app_apple_id self._enable_online_checks = enable_online_checks + if environment == Environment.PRODUCTION and app_apple_id is None: + raise ValueError("appAppleId is required when the environment is Production") def verify_and_decode_renewal_info(self, signed_renewal_info: str) -> JWSRenewalInfoDecodedPayload: """ Verifies and decodes a signedRenewalInfo obtained from the App Store Server API, an App Store Server Notification, or from a device + See https://developer.apple.com/documentation/appstoreserverapi/jwsrenewalinfo :param signed_renewal_info: The signedRenewalInfo field :return: The decoded renewal info after verification @@ -59,8 +63,9 @@ def verify_and_decode_renewal_info(self, signed_renewal_info: str) -> JWSRenewal def verify_and_decode_signed_transaction(self, signed_transaction: str) -> JWSTransactionDecodedPayload: """ Verifies and decodes a signedTransaction obtained from the App Store Server API, an App Store Server Notification, or from a device + See https://developer.apple.com/documentation/appstoreserverapi/jwstransaction - :param signed_transaction: The signedRenewalInfo field + :param signed_transaction: The signedTransaction field :return: The decoded transaction info after verification :throws VerificationException: Thrown if the data could not be verified """ @@ -74,6 +79,7 @@ def verify_and_decode_signed_transaction(self, signed_transaction: str) -> JWSTr def verify_and_decode_notification(self, signed_payload: str) -> ResponseBodyV2DecodedPayload: """ Verifies and decodes an App Store Server Notification signedPayload + See https://developer.apple.com/documentation/appstoreservernotifications/signedpayload :param signedPayload: The payload received by your server :return: The decoded payload after verification @@ -92,15 +98,30 @@ def verify_and_decode_notification(self, signed_payload: str) -> ResponseBodyV2D bundle_id = decoded_signed_notification.summary.bundleId app_apple_id = decoded_signed_notification.summary.appAppleId environment = decoded_signed_notification.summary.environment + elif decoded_signed_notification.externalPurchaseToken: + bundle_id = decoded_signed_notification.externalPurchaseToken.bundleId + app_apple_id = decoded_signed_notification.externalPurchaseToken.appAppleId + if decoded_signed_notification.externalPurchaseToken.externalPurchaseId and decoded_signed_notification.externalPurchaseToken.externalPurchaseId.startswith("SANDBOX"): + environment = Environment.SANDBOX + else: + environment = Environment.PRODUCTION + elif decoded_signed_notification.appData: + bundle_id = decoded_signed_notification.appData.bundleId + app_apple_id = decoded_signed_notification.appData.appAppleId + environment = decoded_signed_notification.appData.environment + self._verify_notification(bundle_id, app_apple_id, environment) + return decoded_signed_notification + + def _verify_notification(self, bundle_id: Optional[str], app_apple_id: Optional[int], environment: Optional[Environment]): if bundle_id != self._bundle_id or (self._environment == Environment.PRODUCTION and app_apple_id != self._app_apple_id): raise VerificationException(VerificationStatus.INVALID_APP_IDENTIFIER) if environment != self._environment: raise VerificationException(VerificationStatus.INVALID_ENVIRONMENT) - return decoded_signed_notification def verify_and_decode_app_transaction(self, signed_app_transaction: str) -> AppTransaction: """ Verifies and decodes a signed AppTransaction + See https://developer.apple.com/documentation/storekit/apptransaction :param signed_app_transaction: The signed AppTransaction :return: The decoded AppTransaction after validation @@ -115,6 +136,23 @@ def verify_and_decode_app_transaction(self, signed_app_transaction: str) -> AppT raise VerificationException(VerificationStatus.INVALID_ENVIRONMENT) return decoded_app_transaction + def verify_and_decode_realtime_request(self, signed_payload: str) -> DecodedRealtimeRequestBody: + """ + Verifies and decodes a Retention Messaging API signedPayload + See https://developer.apple.com/documentation/retentionmessaging/signedpayload + + :param signedPayload: The payload received by your server + :return: The decoded payload after verification + :throws VerificationException: Thrown if the data could not be verified + """ + decoded_dict = self._decode_signed_object(signed_payload) + decoded_realtime_request = _get_cattrs_converter(DecodedRealtimeRequestBody).structure(decoded_dict, DecodedRealtimeRequestBody) + if self._environment == Environment.PRODUCTION and decoded_realtime_request.appAppleId != self._app_apple_id: + raise VerificationException(VerificationStatus.INVALID_APP_IDENTIFIER) + if decoded_realtime_request.environment != self._environment: + raise VerificationException(VerificationStatus.INVALID_ENVIRONMENT) + return decoded_realtime_request + def _decode_signed_object(self, signed_obj: str) -> dict: try: decoded_jwt = jwt.decode(signed_obj, options={"verify_signature": False}) @@ -139,11 +177,26 @@ def _decode_signed_object(self, signed_obj: str) -> dict: raise VerificationException(VerificationStatus.VERIFICATION_FAILURE) from e class _ChainVerifier: + MAXIMUM_CACHE_SIZE = 32 # There are unlikely to be more than a couple keys at once + CACHE_TIME_LIMIT = 15 * 60 # 15 minutes + MAX_SKEW = datetime.timedelta(seconds=60) # Allowable clock skew when validating OCSP response dates + def __init__(self, root_certificates: List[bytes], enable_strict_checks=True): self.enable_strict_checks = enable_strict_checks self.root_certificates = root_certificates + self.verified_certificates_cache: Dict[tuple[str, ...], (str, int)] = {} def verify_chain(self, certificates: List[str], perform_online_checks: bool, effective_date: int) -> str: + if perform_online_checks and len(certificates) > 0: + cached_public_key = self.get_cached_public_key(certificates) + if cached_public_key is not None: + return cached_public_key + verified_public_key = self._verify_chain_without_caching(certificates=certificates, perform_online_checks=perform_online_checks, effective_date=effective_date) + if perform_online_checks: + self.put_verified_public_key(certificates, verified_public_key) + return verified_public_key + + def _verify_chain_without_caching(self, certificates: List[str], perform_online_checks: bool, effective_date: int) -> str: if len(self.root_certificates) == 0: raise VerificationException(VerificationStatus.INVALID_CERTIFICATE) if len(certificates) != 3: @@ -196,12 +249,18 @@ def check_ocsp_status(self, cert: crypto.X509, issuer: crypto.X509, root: crypto ) ocsps = [val for val in authority_values if val.access_method == x509.oid.AuthorityInformationAccessOID.OCSP] for o in ocsps: - r = requests.post( - o.access_location.value, - headers={"Content-Type": "application/ocsp-request"}, - data=req.public_bytes(serialization.Encoding.DER), - ) - if r.status_code == 200: + try: + r = requests.post( + o.access_location.value, + headers={"Content-Type": "application/ocsp-request"}, + data=req.public_bytes(serialization.Encoding.DER), + timeout=30, + ) + except (requests.exceptions.RequestException, OSError) as e: + raise VerificationException(VerificationStatus.RETRYABLE_VERIFICATION_FAILURE) from e + if r.status_code != 200: + raise VerificationException(VerificationStatus.RETRYABLE_VERIFICATION_FAILURE) + else: ocsp_resp = ocsp.load_der_ocsp_response(r.content) if ocsp_resp.response_status == ocsp.OCSPResponseStatus.SUCCESSFUL: certs = [issuer] @@ -269,17 +328,37 @@ def check_ocsp_status(self, cert: crypto.X509, issuer: crypto.X509, root: crypto cert.to_cryptography(), issuer.to_cryptography(), single_response.hash_algorithm ) req = builder.build() + now = datetime.datetime.now(datetime.timezone.utc) if ( single_response.certificate_status == ocsp.OCSPCertStatus.GOOD and single_response.serial_number == req.serial_number and single_response.issuer_key_hash == req.issuer_key_hash and single_response.issuer_name_hash == req.issuer_name_hash + and single_response.this_update_utc is not None + and now + _ChainVerifier.MAX_SKEW >= single_response.this_update_utc + and single_response.next_update_utc is not None + and single_response.next_update_utc >= now - _ChainVerifier.MAX_SKEW ): # Success return raise VerificationException(VerificationStatus.VERIFICATION_FAILURE) + + def get_cached_public_key(self, certificates: List[str]) -> Optional[str]: + verified_public_key = self.verified_certificates_cache.get(tuple(certificates)) + if verified_public_key is None: + return None + if verified_public_key[1] <= time.time(): + return None + return verified_public_key[0] + def put_verified_public_key(self, certificates: List[str], verified_public_key: str): + cache_expiration = time.time() + _ChainVerifier.CACHE_TIME_LIMIT + self.verified_certificates_cache[tuple(certificates)] = (verified_public_key, cache_expiration) + if len(self.verified_certificates_cache) > _ChainVerifier.MAXIMUM_CACHE_SIZE: + for k, v in list(self.verified_certificates_cache.items()): + if v[1] <= time.time(): + del self.verified_certificates_cache[k] class VerificationStatus(IntEnum): OK = 0 @@ -289,6 +368,7 @@ class VerificationStatus(IntEnum): INVALID_CHAIN_LENGTH = 4 INVALID_CHAIN = 5 INVALID_ENVIRONMENT = 6 + RETRYABLE_VERIFICATION_FAILURE = 7 class VerificationException(Exception): diff --git a/docs/requirements.txt b/docs/requirements.txt index faf03223..bc70f9f6 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1 +1 @@ -sphinx == 7.2.2 \ No newline at end of file +sphinx == 9.1.0 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..6f7b36ee --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,34 @@ +# Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "app-store-server-library" +version = "3.1.2" +description = "The App Store Server Library" +readme = {file = "README.md", content-type = "text/markdown"} +license = {text = "MIT"} +requires-python = ">=3.7, <4" +classifiers = [ + "License :: OSI Approved :: MIT License" +] +dependencies = [ + "attrs>=21.3.0", + "PyJWT>=2.6.0,<3", + "requests>=2.28.0,<3", + "cryptography>=43.0.0", + "pyOpenSSL>=23.1.1", + "asn1==3.2.0", + "cattrs>=23.1.2", +] + +[project.optional-dependencies] +async = ["httpx"] + +[tool.setuptools] +packages = {find = {exclude = ["tests"]}} + +[tool.setuptools.package-data] +appstoreserverlibrary = ["py.typed"] diff --git a/requirements.txt b/requirements.txt index 75650d0f..caa730f1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,8 @@ attrs >= 21.3.0 PyJWT >= 2.6.0, < 3 requests >= 2.28.0, < 3 -cryptography >= 40.0.0, < 42 -pyOpenSSL >= 23.1.1, < 24 -asn1==2.7.0 -cattrs==23.1.2 \ No newline at end of file +cryptography >= 43.0.0 +pyOpenSSL >= 23.1.1 +asn1==3.2.0 +cattrs >= 23.1.2 +httpx==0.28.1 diff --git a/setup.py b/setup.py deleted file mode 100644 index 0d5d7b7f..00000000 --- a/setup.py +++ /dev/null @@ -1,20 +0,0 @@ -# Copyright (c) 2023 Apple Inc. Licensed under MIT License. - -import pathlib - -from setuptools import find_packages, setup - -here = pathlib.Path(__file__).parent.resolve() -long_description = (here / "README.md").read_text(encoding="utf-8") - -setup( - name="app-store-server-library", - version="0.2.1", - description="The App Store Server Library", - long_description=long_description, - long_description_content_type="text/markdown", - packages=find_packages(exclude=["tests"]), - python_requires=">=3.7, <4", - install_requires=["attrs >= 21.3.0", 'PyJWT >= 2.6.0, < 3', 'requests >= 2.28.0, < 3', 'cryptography >= 40.0.0, < 42', 'pyOpenSSL >= 23.1.1, < 24', 'asn1==2.7.0', 'cattrs==23.1.2'], - package_data={"appstoreserverlibrary": ["py.typed"]}, -) diff --git a/tests/resources/models/advancedCommerceDescriptors.json b/tests/resources/models/advancedCommerceDescriptors.json new file mode 100644 index 00000000..5fe14d4c --- /dev/null +++ b/tests/resources/models/advancedCommerceDescriptors.json @@ -0,0 +1,4 @@ +{ + "description": "description", + "displayName": "display name" +} diff --git a/tests/resources/models/advancedCommerceOffer.json b/tests/resources/models/advancedCommerceOffer.json new file mode 100644 index 00000000..83a413fb --- /dev/null +++ b/tests/resources/models/advancedCommerceOffer.json @@ -0,0 +1,6 @@ +{ + "period": "P1W", + "periodCount": 3, + "price": 5000, + "reason": "WIN_BACK" +} diff --git a/tests/resources/models/advancedCommerceOneTimeChargeCreateRequest.json b/tests/resources/models/advancedCommerceOneTimeChargeCreateRequest.json new file mode 100644 index 00000000..b81f2783 --- /dev/null +++ b/tests/resources/models/advancedCommerceOneTimeChargeCreateRequest.json @@ -0,0 +1,14 @@ +{ + "currency": "USD", + "item": { + "description": "description", + "displayName": "display name", + "SKU": "sku", + "price": 10000 + }, + "requestInfo": { + "requestReferenceId": "550e8400-e29b-41d4-a716-446655440000" + }, + "taxCode": "taxCode", + "storefront": "USA" +} diff --git a/tests/resources/models/advancedCommerceOneTimeChargeItem.json b/tests/resources/models/advancedCommerceOneTimeChargeItem.json new file mode 100644 index 00000000..0849bf28 --- /dev/null +++ b/tests/resources/models/advancedCommerceOneTimeChargeItem.json @@ -0,0 +1,6 @@ +{ + "description": "description", + "displayName": "display name", + "SKU": "sku", + "price": 15000 +} diff --git a/tests/resources/models/advancedCommerceRequestInfo.json b/tests/resources/models/advancedCommerceRequestInfo.json new file mode 100644 index 00000000..a57cbdca --- /dev/null +++ b/tests/resources/models/advancedCommerceRequestInfo.json @@ -0,0 +1,5 @@ +{ + "requestReferenceId": "550e8400-e29b-41d4-a716-446655440010", + "appAccountToken": "660e8400-e29b-41d4-a716-446655440011", + "consistencyToken": "consistency_token_value" +} diff --git a/tests/resources/models/advancedCommerceRequestRefundItem.json b/tests/resources/models/advancedCommerceRequestRefundItem.json new file mode 100644 index 00000000..875107fa --- /dev/null +++ b/tests/resources/models/advancedCommerceRequestRefundItem.json @@ -0,0 +1,7 @@ +{ + "SKU": "sku", + "refundReason": "LEGAL", + "refundType": "FULL", + "revoke": true, + "refundAmount": 5000 +} diff --git a/tests/resources/models/advancedCommerceRequestRefundRequest.json b/tests/resources/models/advancedCommerceRequestRefundRequest.json new file mode 100644 index 00000000..6a58aa7a --- /dev/null +++ b/tests/resources/models/advancedCommerceRequestRefundRequest.json @@ -0,0 +1,22 @@ +{ + "items": [ + { + "SKU": "sku", + "refundReason": "LEGAL", + "refundType": "FULL", + "revoke": true + }, + { + "SKU": "sku", + "refundReason": "OTHER", + "refundType": "PRORATED", + "revoke": false + } + ], + "refundRiskingPreference": true, + "requestInfo": { + "requestReferenceId": "550e8400-e29b-41d4-a716-446655440002" + }, + "currency": "USD", + "storefront": "USA" +} diff --git a/tests/resources/models/advancedCommerceRequestRefundResponse.json b/tests/resources/models/advancedCommerceRequestRefundResponse.json new file mode 100644 index 00000000..0e4f409c --- /dev/null +++ b/tests/resources/models/advancedCommerceRequestRefundResponse.json @@ -0,0 +1,3 @@ +{ + "signedTransactionInfo": "signed_transaction_info_value" +} diff --git a/tests/resources/models/advancedCommerceSubscriptionCancelRequest.json b/tests/resources/models/advancedCommerceSubscriptionCancelRequest.json new file mode 100644 index 00000000..9cbe08c5 --- /dev/null +++ b/tests/resources/models/advancedCommerceSubscriptionCancelRequest.json @@ -0,0 +1,6 @@ +{ + "requestInfo": { + "requestReferenceId": "550e8400-e29b-41d4-a716-446655440003" + }, + "storefront": "USA" +} diff --git a/tests/resources/models/advancedCommerceSubscriptionCancelResponse.json b/tests/resources/models/advancedCommerceSubscriptionCancelResponse.json new file mode 100644 index 00000000..6f14b309 --- /dev/null +++ b/tests/resources/models/advancedCommerceSubscriptionCancelResponse.json @@ -0,0 +1,4 @@ +{ + "signedRenewalInfo": "signed_renewal_info", + "signedTransactionInfo": "signed_transaction_info" +} diff --git a/tests/resources/models/advancedCommerceSubscriptionChangeMetadataDescriptors.json b/tests/resources/models/advancedCommerceSubscriptionChangeMetadataDescriptors.json new file mode 100644 index 00000000..b07d7400 --- /dev/null +++ b/tests/resources/models/advancedCommerceSubscriptionChangeMetadataDescriptors.json @@ -0,0 +1,5 @@ +{ + "effective": "IMMEDIATELY", + "description": "description", + "displayName": "displayName" +} diff --git a/tests/resources/models/advancedCommerceSubscriptionChangeMetadataItem.json b/tests/resources/models/advancedCommerceSubscriptionChangeMetadataItem.json new file mode 100644 index 00000000..c498b6e5 --- /dev/null +++ b/tests/resources/models/advancedCommerceSubscriptionChangeMetadataItem.json @@ -0,0 +1,7 @@ +{ + "currentSKU": "currentSku", + "effective": "NEXT_BILL_CYCLE", + "description": "description", + "displayName": "displayName", + "SKU": "sku" +} diff --git a/tests/resources/models/advancedCommerceSubscriptionChangeMetadataRequest.json b/tests/resources/models/advancedCommerceSubscriptionChangeMetadataRequest.json new file mode 100644 index 00000000..20a1e396 --- /dev/null +++ b/tests/resources/models/advancedCommerceSubscriptionChangeMetadataRequest.json @@ -0,0 +1,14 @@ +{ + "requestInfo": { + "requestReferenceId": "550e8400-e29b-41d4-a716-446655440009" + }, + "items": [ + { + "currentSKU": "currentSKU", + "effective": "IMMEDIATELY", + "description": "description", + "displayName": "display name", + "SKU": "sku" + } + ] +} diff --git a/tests/resources/models/advancedCommerceSubscriptionChangeMetadataResponse.json b/tests/resources/models/advancedCommerceSubscriptionChangeMetadataResponse.json new file mode 100644 index 00000000..6f14b309 --- /dev/null +++ b/tests/resources/models/advancedCommerceSubscriptionChangeMetadataResponse.json @@ -0,0 +1,4 @@ +{ + "signedRenewalInfo": "signed_renewal_info", + "signedTransactionInfo": "signed_transaction_info" +} diff --git a/tests/resources/models/advancedCommerceSubscriptionCreateItem.json b/tests/resources/models/advancedCommerceSubscriptionCreateItem.json new file mode 100644 index 00000000..f1b00ea2 --- /dev/null +++ b/tests/resources/models/advancedCommerceSubscriptionCreateItem.json @@ -0,0 +1,6 @@ +{ + "description": "description", + "displayName": "display name", + "SKU": "sku", + "price": 20000 +} diff --git a/tests/resources/models/advancedCommerceSubscriptionCreateRequest.json b/tests/resources/models/advancedCommerceSubscriptionCreateRequest.json new file mode 100644 index 00000000..2b7a132c --- /dev/null +++ b/tests/resources/models/advancedCommerceSubscriptionCreateRequest.json @@ -0,0 +1,28 @@ +{ + "currency": "USD", + "descriptors": { + "description": "description", + "displayName": "display name" + }, + "items": [ + { + "SKU": "sku", + "description": "description", + "displayName": "display name", + "price": 20000 + }, + { + "SKU": "sku", + "description": "description", + "displayName": "display name", + "price": 30000 + } + ], + "period": "P1M", + "requestInfo": { + "requestReferenceId": "550e8400-e29b-41d4-a716-446655440001" + }, + "taxCode": "taxCode", + "storefront": "USA", + "previousTransactionId": "transactionId" +} diff --git a/tests/resources/models/advancedCommerceSubscriptionMigrateDescriptors.json b/tests/resources/models/advancedCommerceSubscriptionMigrateDescriptors.json new file mode 100644 index 00000000..6d0bd6ee --- /dev/null +++ b/tests/resources/models/advancedCommerceSubscriptionMigrateDescriptors.json @@ -0,0 +1,4 @@ +{ + "description": "description", + "displayName": "displayName" +} diff --git a/tests/resources/models/advancedCommerceSubscriptionMigrateItem.json b/tests/resources/models/advancedCommerceSubscriptionMigrateItem.json new file mode 100644 index 00000000..00fce710 --- /dev/null +++ b/tests/resources/models/advancedCommerceSubscriptionMigrateItem.json @@ -0,0 +1,5 @@ +{ + "SKU": "sku", + "description": "description", + "displayName": "displayName" +} diff --git a/tests/resources/models/advancedCommerceSubscriptionMigrateRenewalItem.json b/tests/resources/models/advancedCommerceSubscriptionMigrateRenewalItem.json new file mode 100644 index 00000000..00fce710 --- /dev/null +++ b/tests/resources/models/advancedCommerceSubscriptionMigrateRenewalItem.json @@ -0,0 +1,5 @@ +{ + "SKU": "sku", + "description": "description", + "displayName": "displayName" +} diff --git a/tests/resources/models/advancedCommerceSubscriptionMigrateRequest.json b/tests/resources/models/advancedCommerceSubscriptionMigrateRequest.json new file mode 100644 index 00000000..d670da51 --- /dev/null +++ b/tests/resources/models/advancedCommerceSubscriptionMigrateRequest.json @@ -0,0 +1,18 @@ +{ + "requestInfo": { + "requestReferenceId": "550e8400-e29b-41d4-a716-446655440006" + }, + "descriptors": { + "description": "description", + "displayName": "display name" + }, + "items": [ + { + "SKU": "sku", + "description": "description", + "displayName": "display name" + } + ], + "targetProductId": "targetProductId", + "taxCode": "taxCode" +} diff --git a/tests/resources/models/advancedCommerceSubscriptionMigrateResponse.json b/tests/resources/models/advancedCommerceSubscriptionMigrateResponse.json new file mode 100644 index 00000000..2b12972a --- /dev/null +++ b/tests/resources/models/advancedCommerceSubscriptionMigrateResponse.json @@ -0,0 +1,4 @@ +{ + "signedRenewalInfo": "signed_renewal_info_value", + "signedTransactionInfo": "signed_transaction_info_value" +} diff --git a/tests/resources/models/advancedCommerceSubscriptionModifyAddItem.json b/tests/resources/models/advancedCommerceSubscriptionModifyAddItem.json new file mode 100644 index 00000000..935ffb42 --- /dev/null +++ b/tests/resources/models/advancedCommerceSubscriptionModifyAddItem.json @@ -0,0 +1,6 @@ +{ + "SKU": "sku", + "description": "description", + "displayName": "displayName", + "price": 12000 +} diff --git a/tests/resources/models/advancedCommerceSubscriptionModifyChangeItem.json b/tests/resources/models/advancedCommerceSubscriptionModifyChangeItem.json new file mode 100644 index 00000000..ac33673d --- /dev/null +++ b/tests/resources/models/advancedCommerceSubscriptionModifyChangeItem.json @@ -0,0 +1,9 @@ +{ + "currentSKU": "currentSku", + "description": "description", + "displayName": "displayName", + "effective": "IMMEDIATELY", + "price": 13000, + "reason": "UPGRADE", + "SKU": "sku" +} diff --git a/tests/resources/models/advancedCommerceSubscriptionModifyDescriptors.json b/tests/resources/models/advancedCommerceSubscriptionModifyDescriptors.json new file mode 100644 index 00000000..b07d7400 --- /dev/null +++ b/tests/resources/models/advancedCommerceSubscriptionModifyDescriptors.json @@ -0,0 +1,5 @@ +{ + "effective": "IMMEDIATELY", + "description": "description", + "displayName": "displayName" +} diff --git a/tests/resources/models/advancedCommerceSubscriptionModifyInAppRequest.json b/tests/resources/models/advancedCommerceSubscriptionModifyInAppRequest.json new file mode 100644 index 00000000..0934cb8c --- /dev/null +++ b/tests/resources/models/advancedCommerceSubscriptionModifyInAppRequest.json @@ -0,0 +1,14 @@ +{ + "requestInfo": { + "requestReferenceId": "550e8400-e29b-41d4-a716-446655440007" + }, + "transactionId": "transactionId", + "retainBillingCycle": true, + "descriptors": { + "effective": "IMMEDIATELY", + "description": "description", + "displayName": "display name" + }, + "taxCode": "taxCode", + "currency": "USD" +} diff --git a/tests/resources/models/advancedCommerceSubscriptionModifyPeriodChange.json b/tests/resources/models/advancedCommerceSubscriptionModifyPeriodChange.json new file mode 100644 index 00000000..b46ebb58 --- /dev/null +++ b/tests/resources/models/advancedCommerceSubscriptionModifyPeriodChange.json @@ -0,0 +1,4 @@ +{ + "effective": "IMMEDIATELY", + "period": "P3M" +} diff --git a/tests/resources/models/advancedCommerceSubscriptionModifyRemoveItem.json b/tests/resources/models/advancedCommerceSubscriptionModifyRemoveItem.json new file mode 100644 index 00000000..c9767fb0 --- /dev/null +++ b/tests/resources/models/advancedCommerceSubscriptionModifyRemoveItem.json @@ -0,0 +1,3 @@ +{ + "SKU": "sku" +} diff --git a/tests/resources/models/advancedCommerceSubscriptionPriceChangeItem.json b/tests/resources/models/advancedCommerceSubscriptionPriceChangeItem.json new file mode 100644 index 00000000..8a4b90f0 --- /dev/null +++ b/tests/resources/models/advancedCommerceSubscriptionPriceChangeItem.json @@ -0,0 +1,5 @@ +{ + "SKU": "sku", + "price": 16000, + "dependentSKUs": ["dependentSKU"] +} diff --git a/tests/resources/models/advancedCommerceSubscriptionPriceChangeRequest.json b/tests/resources/models/advancedCommerceSubscriptionPriceChangeRequest.json new file mode 100644 index 00000000..fba8aded --- /dev/null +++ b/tests/resources/models/advancedCommerceSubscriptionPriceChangeRequest.json @@ -0,0 +1,12 @@ +{ + "requestInfo": { + "requestReferenceId": "550e8400-e29b-41d4-a716-446655440005" + }, + "items": [ + { + "SKU": "sku123", + "price": 15000 + } + ], + "currency": "USD" +} diff --git a/tests/resources/models/advancedCommerceSubscriptionPriceChangeResponse.json b/tests/resources/models/advancedCommerceSubscriptionPriceChangeResponse.json new file mode 100644 index 00000000..6f14b309 --- /dev/null +++ b/tests/resources/models/advancedCommerceSubscriptionPriceChangeResponse.json @@ -0,0 +1,4 @@ +{ + "signedRenewalInfo": "signed_renewal_info", + "signedTransactionInfo": "signed_transaction_info" +} diff --git a/tests/resources/models/advancedCommerceSubscriptionReactivateInAppRequest.json b/tests/resources/models/advancedCommerceSubscriptionReactivateInAppRequest.json new file mode 100644 index 00000000..408a99b3 --- /dev/null +++ b/tests/resources/models/advancedCommerceSubscriptionReactivateInAppRequest.json @@ -0,0 +1,11 @@ +{ + "requestInfo": { + "requestReferenceId": "550e8400-e29b-41d4-a716-446655440008" + }, + "transactionId": "transactionId", + "items": [ + { + "SKU": "sku" + } + ] +} diff --git a/tests/resources/models/advancedCommerceSubscriptionReactivateItem.json b/tests/resources/models/advancedCommerceSubscriptionReactivateItem.json new file mode 100644 index 00000000..c9767fb0 --- /dev/null +++ b/tests/resources/models/advancedCommerceSubscriptionReactivateItem.json @@ -0,0 +1,3 @@ +{ + "SKU": "sku" +} diff --git a/tests/resources/models/advancedCommerceSubscriptionRevokeRequest.json b/tests/resources/models/advancedCommerceSubscriptionRevokeRequest.json new file mode 100644 index 00000000..d723be45 --- /dev/null +++ b/tests/resources/models/advancedCommerceSubscriptionRevokeRequest.json @@ -0,0 +1,9 @@ +{ + "requestInfo": { + "requestReferenceId": "550e8400-e29b-41d4-a716-446655440004" + }, + "refundRiskingPreference": true, + "refundReason": "LEGAL", + "refundType": "FULL", + "storefront": "USA" +} diff --git a/tests/resources/models/advancedCommerceSubscriptionRevokeResponse.json b/tests/resources/models/advancedCommerceSubscriptionRevokeResponse.json new file mode 100644 index 00000000..6f14b309 --- /dev/null +++ b/tests/resources/models/advancedCommerceSubscriptionRevokeResponse.json @@ -0,0 +1,4 @@ +{ + "signedRenewalInfo": "signed_renewal_info", + "signedTransactionInfo": "signed_transaction_info" +} diff --git a/tests/resources/models/appData.json b/tests/resources/models/appData.json new file mode 100644 index 00000000..14b93acb --- /dev/null +++ b/tests/resources/models/appData.json @@ -0,0 +1,6 @@ +{ + "appAppleId": 987654321, + "bundleId": "com.example", + "environment": "Sandbox", + "signedAppTransactionInfo": "signed-app-transaction-info" +} \ No newline at end of file diff --git a/tests/resources/models/appTransaction.json b/tests/resources/models/appTransaction.json index b3b937b8..9313c31c 100644 --- a/tests/resources/models/appTransaction.json +++ b/tests/resources/models/appTransaction.json @@ -9,5 +9,7 @@ "originalApplicationVersion": "1.1.2", "deviceVerification": "device_verification_value", "deviceVerificationNonce": "48ccfa42-7431-4f22-9908-7e88983e105a", - "preorderDate": 1698148700000 + "preorderDate": 1698148700000, + "appTransactionId": "71134", + "originalPlatform": "iOS" } \ No newline at end of file diff --git a/tests/resources/models/appTransactionDoesNotExistError.json b/tests/resources/models/appTransactionDoesNotExistError.json new file mode 100644 index 00000000..a5730eae --- /dev/null +++ b/tests/resources/models/appTransactionDoesNotExistError.json @@ -0,0 +1,4 @@ +{ + "errorCode": 4040019, + "errorMessage": "No AppTransaction exists for the customer." +} \ No newline at end of file diff --git a/tests/resources/models/appTransactionInfoResponse.json b/tests/resources/models/appTransactionInfoResponse.json new file mode 100644 index 00000000..2cef0346 --- /dev/null +++ b/tests/resources/models/appTransactionInfoResponse.json @@ -0,0 +1,3 @@ +{ + "signedAppTransactionInfo": "signed_app_transaction_info_value" +} \ No newline at end of file diff --git a/tests/resources/models/decodedRealtimeRequest.json b/tests/resources/models/decodedRealtimeRequest.json new file mode 100644 index 00000000..57e184b7 --- /dev/null +++ b/tests/resources/models/decodedRealtimeRequest.json @@ -0,0 +1,9 @@ +{ + "originalTransactionId": "99371282", + "appAppleId": 531412, + "productId": "com.example.product", + "userLocale": "en-US", + "requestIdentifier": "3db5c98d-8acf-4e29-831e-8e1f82f9f6e9", + "environment": "LocalTesting", + "signedDate": 1698148900000 +} diff --git a/tests/resources/models/familyTransactionNotSupportedError.json b/tests/resources/models/familyTransactionNotSupportedError.json new file mode 100644 index 00000000..54af6742 --- /dev/null +++ b/tests/resources/models/familyTransactionNotSupportedError.json @@ -0,0 +1,4 @@ +{ + "errorCode": 4000185, + "errorMessage": "Invalid request. Family Sharing transactions aren't supported by this endpoint." +} \ No newline at end of file diff --git a/tests/resources/models/getDefaultMessageResponse.json b/tests/resources/models/getDefaultMessageResponse.json new file mode 100644 index 00000000..e3d2954b --- /dev/null +++ b/tests/resources/models/getDefaultMessageResponse.json @@ -0,0 +1,3 @@ +{ + "messageIdentifier": "a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890" +} diff --git a/tests/resources/models/getImageListResponse.json b/tests/resources/models/getImageListResponse.json new file mode 100644 index 00000000..b668dd12 --- /dev/null +++ b/tests/resources/models/getImageListResponse.json @@ -0,0 +1,9 @@ +{ + "imageIdentifiers": [ + { + "imageIdentifier": "a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890", + "imageState": "APPROVED", + "imageSize": "FULL_SIZE" + } + ] +} diff --git a/tests/resources/models/getMessageListResponse.json b/tests/resources/models/getMessageListResponse.json new file mode 100644 index 00000000..4ccd8fea --- /dev/null +++ b/tests/resources/models/getMessageListResponse.json @@ -0,0 +1,8 @@ +{ + "messageIdentifiers": [ + { + "messageIdentifier": "a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890", + "messageState": "APPROVED" + } + ] +} diff --git a/tests/resources/models/getRealtimeUrlResponse.json b/tests/resources/models/getRealtimeUrlResponse.json new file mode 100644 index 00000000..699d7fb5 --- /dev/null +++ b/tests/resources/models/getRealtimeUrlResponse.json @@ -0,0 +1,3 @@ +{ + "realtimeURL": "https://example.com/realtime" +} diff --git a/tests/resources/models/invalidAppAccountTokenUUIDError.json b/tests/resources/models/invalidAppAccountTokenUUIDError.json new file mode 100644 index 00000000..2045fed0 --- /dev/null +++ b/tests/resources/models/invalidAppAccountTokenUUIDError.json @@ -0,0 +1,4 @@ +{ + "errorCode": 4000183, + "errorMessage": "Invalid request. The app account token field must be a valid UUID." +} \ No newline at end of file diff --git a/tests/resources/models/invalidTransactionIdError.json b/tests/resources/models/invalidTransactionIdError.json new file mode 100644 index 00000000..32fc281a --- /dev/null +++ b/tests/resources/models/invalidTransactionIdError.json @@ -0,0 +1,4 @@ +{ + "errorCode": 4000006, + "errorMessage": "Invalid transaction id." +} \ No newline at end of file diff --git a/tests/resources/models/performanceTestResponse.json b/tests/resources/models/performanceTestResponse.json new file mode 100644 index 00000000..09973994 --- /dev/null +++ b/tests/resources/models/performanceTestResponse.json @@ -0,0 +1,10 @@ +{ + "config": { + "maxConcurrentRequests": 10, + "totalRequests": 100, + "totalDuration": 60000, + "responseTimeThreshold": 500, + "successRateThreshold": 95 + }, + "requestId": "c4b87a1d-2e3f-4a5b-9c6d-7e8f9a0b1c2d" +} diff --git a/tests/resources/models/performanceTestResultResponse.json b/tests/resources/models/performanceTestResultResponse.json new file mode 100644 index 00000000..a2ac006a --- /dev/null +++ b/tests/resources/models/performanceTestResultResponse.json @@ -0,0 +1,24 @@ +{ + "config": { + "maxConcurrentRequests": 10, + "totalRequests": 100, + "totalDuration": 60000, + "responseTimeThreshold": 500, + "successRateThreshold": 95 + }, + "target": "https://example.com/retention", + "result": "PASS", + "successRate": 98, + "numPending": 0, + "responseTimes": { + "average": 120, + "p50": 100, + "p90": 200, + "p95": 250, + "p99": 400 + }, + "failures": { + "TIMED_OUT": 1, + "NO_RESPONSE": 1 + } +} diff --git a/tests/resources/models/signedConsumptionRequestNotification.json b/tests/resources/models/signedConsumptionRequestNotification.json new file mode 100644 index 00000000..56c27c75 --- /dev/null +++ b/tests/resources/models/signedConsumptionRequestNotification.json @@ -0,0 +1,16 @@ +{ + "notificationType": "CONSUMPTION_REQUEST", + "notificationUUID": "002e14d5-51f5-4503-b5a8-c3a1af68eb20", + "data": { + "environment": "LocalTesting", + "appAppleId": 41234, + "bundleId": "com.example", + "bundleVersion": "1.2.3", + "signedTransactionInfo": "signed_transaction_info_value", + "signedRenewalInfo": "signed_renewal_info_value", + "status": 1, + "consumptionRequestReason": "UNINTENDED_PURCHASE" + }, + "version": "2.0", + "signedDate": 1698148900000 +} \ No newline at end of file diff --git a/tests/resources/models/signedExternalPurchaseTokenNotification.json b/tests/resources/models/signedExternalPurchaseTokenNotification.json new file mode 100644 index 00000000..479785b1 --- /dev/null +++ b/tests/resources/models/signedExternalPurchaseTokenNotification.json @@ -0,0 +1,13 @@ +{ + "notificationType": "EXTERNAL_PURCHASE_TOKEN", + "subtype": "UNREPORTED", + "notificationUUID": "002e14d5-51f5-4503-b5a8-c3a1af68eb20", + "version": "2.0", + "signedDate": 1698148900000, + "externalPurchaseToken": { + "externalPurchaseId": "b2158121-7af9-49d4-9561-1f588205523e", + "tokenCreationDate": 1698148950000, + "appAppleId": 55555, + "bundleId": "com.example" + } + } \ No newline at end of file diff --git a/tests/resources/models/signedExternalPurchaseTokenSandboxNotification.json b/tests/resources/models/signedExternalPurchaseTokenSandboxNotification.json new file mode 100644 index 00000000..509cdd64 --- /dev/null +++ b/tests/resources/models/signedExternalPurchaseTokenSandboxNotification.json @@ -0,0 +1,13 @@ +{ + "notificationType": "EXTERNAL_PURCHASE_TOKEN", + "subtype": "UNREPORTED", + "notificationUUID": "002e14d5-51f5-4503-b5a8-c3a1af68eb20", + "version": "2.0", + "signedDate": 1698148900000, + "externalPurchaseToken": { + "externalPurchaseId": "SANDBOX_b2158121-7af9-49d4-9561-1f588205523e", + "tokenCreationDate": 1698148950000, + "appAppleId": 55555, + "bundleId": "com.example" + } + } \ No newline at end of file diff --git a/tests/resources/models/signedRenewalInfo.json b/tests/resources/models/signedRenewalInfo.json index 17c07a8e..1cbce07b 100644 --- a/tests/resources/models/signedRenewalInfo.json +++ b/tests/resources/models/signedRenewalInfo.json @@ -12,5 +12,49 @@ "signedDate": 1698148800000, "environment": "LocalTesting", "recentSubscriptionStartDate": 1698148800000, - "renewalDate": 1698148850000 -} \ No newline at end of file + "renewalDate": 1698148850000, + "renewalPrice": 9990, + "currency": "USD", + "offerDiscountType": "PAY_AS_YOU_GO", + "eligibleWinBackOfferIds": [ + "eligible1", + "eligible2" + ], + "appTransactionId": "71134", + "offerPeriod": "P1Y", + "appAccountToken": "7e3fb20b-4cdb-47cc-936d-99d65f608138", + "advancedCommerceInfo": { + "consistencyToken": "token-abc-123", + "descriptors": { + "description": "Premium Plan", + "displayName": "Premium" + }, + "items": [ + { + "SKU": "com.example.sku.premium", + "description": "Premium feature", + "displayName": "Premium Feature", + "price": 9990, + "priceIncreaseInfo": { + "dependentSKUs": [ + "com.example.sku.1", + "com.example.sku.2" + ], + "price": 12990, + "status": "PENDING" + } + } + ], + "period": "P1M", + "requestReferenceId": "ref-12345", + "taxCode": "TAX_CODE_1" + }, + "commitmentInfo": { + "commitmentAutoRenewProductId": "com.example.product.commitment", + "commitmentAutoRenewStatus": 1, + "commitmentRenewalBillingPlanType": "MONTHLY", + "commitmentRenewalDate": 1698149500000, + "commitmentRenewalPrice": 9990 + }, + "renewalBillingPlanType": "MONTHLY" +} diff --git a/tests/resources/models/signedRescindConsentNotification.json b/tests/resources/models/signedRescindConsentNotification.json new file mode 100644 index 00000000..0624e932 --- /dev/null +++ b/tests/resources/models/signedRescindConsentNotification.json @@ -0,0 +1,12 @@ +{ + "notificationType": "RESCIND_CONSENT", + "notificationUUID": "002e14d5-51f5-4503-b5a8-c3a1af68eb20", + "appData": { + "appAppleId": 41234, + "bundleId": "com.example", + "environment": "LocalTesting", + "signedAppTransactionInfo": "signed_app_transaction_info_value" + }, + "version": "2.0", + "signedDate": 1698148900000 +} \ No newline at end of file diff --git a/tests/resources/models/signedTransaction.json b/tests/resources/models/signedTransaction.json index 0211857b..3d405b64 100644 --- a/tests/resources/models/signedTransaction.json +++ b/tests/resources/models/signedTransaction.json @@ -24,5 +24,43 @@ "storefrontId":"143441", "price": 10990, "currency": "USD", - "offerDiscountType": "PAY_AS_YOU_GO" + "offerDiscountType": "PAY_AS_YOU_GO", + "appTransactionId": "71134", + "offerPeriod": "P1Y", + "advancedCommerceInfo": { + "descriptors": { + "description": "Premium Plan", + "displayName": "Premium" + }, + "estimatedTax": 1500, + "items": [ + { + "SKU": "com.example.sku.premium", + "description": "Premium feature", + "displayName": "Premium Feature", + "price": 9990, + "refunds": [ + { + "refundAmount": 5000, + "refundDate": 1698149100000, + "refundReason": "FULFILLMENT_ISSUE", + "refundType": "PRORATED" + } + ], + "revocationDate": 1698149200000 + } + ], + "period": "P1M", + "requestReferenceId": "ref-12345", + "taxCode": "TAX_CODE_1", + "taxExclusivePrice": 8490, + "taxRate": "0.15" + }, + "billingPlanType": "MONTHLY", + "commitmentInfo": { + "billingPeriodNumber": 3, + "commitmentExpiresDate": 1698150000000, + "commitmentPrice": 119880, + "totalBillingPeriods": 12 + } } \ No newline at end of file diff --git a/tests/resources/models/signedTransactionWithRevocation.json b/tests/resources/models/signedTransactionWithRevocation.json new file mode 100644 index 00000000..3886b2e1 --- /dev/null +++ b/tests/resources/models/signedTransactionWithRevocation.json @@ -0,0 +1,32 @@ +{ + "originalTransactionId": "12345", + "transactionId": "23456", + "webOrderLineItemId": "34343", + "bundleId": "com.example", + "productId": "com.example.product", + "subscriptionGroupIdentifier": "55555", + "purchaseDate": 1698148900000, + "originalPurchaseDate": 1698148800000, + "expiresDate": 1698149000000, + "quantity": 1, + "type": "Auto-Renewable Subscription", + "appAccountToken": "7e3fb20b-4cdb-47cc-936d-99d65f608138", + "inAppOwnershipType": "PURCHASED", + "signedDate": 1698148900000, + "revocationReason": 1, + "revocationDate": 1698148950000, + "isUpgraded": true, + "offerType": 1, + "offerIdentifier": "abc.123", + "environment": "LocalTesting", + "storefront": "USA", + "storefrontId": "143441", + "transactionReason": "PURCHASE", + "price": 10990, + "currency": "USD", + "offerDiscountType": "PAY_AS_YOU_GO", + "appTransactionId": "71134", + "offerPeriod": "P1Y", + "revocationType": "REFUND_PRORATED", + "revocationPercentage": 50000 +} \ No newline at end of file diff --git a/tests/resources/models/transactionHistoryResponseWithMalformedAppAppleId.json b/tests/resources/models/transactionHistoryResponseWithMalformedAppAppleId.json new file mode 100644 index 00000000..29cd990c --- /dev/null +++ b/tests/resources/models/transactionHistoryResponseWithMalformedAppAppleId.json @@ -0,0 +1,11 @@ +{ + "revision": "revision_output", + "hasMore": 1, + "bundleId": "com.example", + "appAppleId": "hi", + "environment": "LocalTesting", + "signedTransactions": [ + "signed_transaction_value", + "signed_transaction_value2" + ] + } \ No newline at end of file diff --git a/tests/resources/models/transactionHistoryResponseWithMalformedEnvironment.json b/tests/resources/models/transactionHistoryResponseWithMalformedEnvironment.json new file mode 100644 index 00000000..9d5d56db --- /dev/null +++ b/tests/resources/models/transactionHistoryResponseWithMalformedEnvironment.json @@ -0,0 +1,11 @@ +{ + "revision": "revision_output", + "hasMore": true, + "bundleId": "com.example", + "appAppleId": 323232, + "environment": "LocalTestingxxx", + "signedTransactions": [ + "signed_transaction_value", + "signed_transaction_value2" + ] + } \ No newline at end of file diff --git a/tests/resources/models/transactionIdNotFoundError.json b/tests/resources/models/transactionIdNotFoundError.json new file mode 100644 index 00000000..f445639b --- /dev/null +++ b/tests/resources/models/transactionIdNotFoundError.json @@ -0,0 +1,4 @@ +{ + "errorCode": 4040010, + "errorMessage": "Transaction id not found." +} \ No newline at end of file diff --git a/tests/resources/models/transactionIdNotOriginalTransactionId.json b/tests/resources/models/transactionIdNotOriginalTransactionId.json new file mode 100644 index 00000000..02f33b18 --- /dev/null +++ b/tests/resources/models/transactionIdNotOriginalTransactionId.json @@ -0,0 +1,4 @@ +{ + "errorCode": 4000187, + "errorMessage": "Invalid request. The transaction ID provided is not an original transaction ID." +} \ No newline at end of file diff --git a/tests/test_advanced_commerce_models.py b/tests/test_advanced_commerce_models.py new file mode 100644 index 00000000..93e2315d --- /dev/null +++ b/tests/test_advanced_commerce_models.py @@ -0,0 +1,712 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +import json +import unittest + +from appstoreserverlibrary.models.AdvancedCommerceDescriptors import AdvancedCommerceDescriptors +from appstoreserverlibrary.models.AdvancedCommerceEffective import AdvancedCommerceEffective +from appstoreserverlibrary.models.AdvancedCommerceOffer import AdvancedCommerceOffer +from appstoreserverlibrary.models.AdvancedCommerceOfferPeriod import AdvancedCommerceOfferPeriod +from appstoreserverlibrary.models.AdvancedCommerceOfferReason import AdvancedCommerceOfferReason +from appstoreserverlibrary.models.AdvancedCommerceOneTimeChargeCreateRequest import AdvancedCommerceOneTimeChargeCreateRequest +from appstoreserverlibrary.models.AdvancedCommerceOneTimeChargeItem import AdvancedCommerceOneTimeChargeItem +from appstoreserverlibrary.models.AdvancedCommercePeriod import AdvancedCommercePeriod +from appstoreserverlibrary.models.AdvancedCommercePriceIncreaseInfo import AdvancedCommercePriceIncreaseInfo +from appstoreserverlibrary.models.AdvancedCommercePriceIncreaseInfoStatus import AdvancedCommercePriceIncreaseInfoStatus +from appstoreserverlibrary.models.AdvancedCommerceReason import AdvancedCommerceReason +from appstoreserverlibrary.models.AdvancedCommerceRefund import AdvancedCommerceRefund +from appstoreserverlibrary.models.AdvancedCommerceRefundReason import AdvancedCommerceRefundReason +from appstoreserverlibrary.models.AdvancedCommerceRefundType import AdvancedCommerceRefundType +from appstoreserverlibrary.models.AdvancedCommerceRenewalInfo import AdvancedCommerceRenewalInfo +from appstoreserverlibrary.models.AdvancedCommerceRenewalItem import AdvancedCommerceRenewalItem +from appstoreserverlibrary.models.AdvancedCommerceRequestInfo import AdvancedCommerceRequestInfo +from appstoreserverlibrary.models.AdvancedCommerceRequestRefundItem import AdvancedCommerceRequestRefundItem +from appstoreserverlibrary.models.AdvancedCommerceRequestRefundRequest import AdvancedCommerceRequestRefundRequest +from appstoreserverlibrary.models.AdvancedCommerceRequestRefundResponse import AdvancedCommerceRequestRefundResponse +from appstoreserverlibrary.models.AdvancedCommerceSubscriptionCancelRequest import AdvancedCommerceSubscriptionCancelRequest +from appstoreserverlibrary.models.AdvancedCommerceSubscriptionCancelResponse import AdvancedCommerceSubscriptionCancelResponse +from appstoreserverlibrary.models.AdvancedCommerceSubscriptionChangeMetadataDescriptors import AdvancedCommerceSubscriptionChangeMetadataDescriptors +from appstoreserverlibrary.models.AdvancedCommerceSubscriptionChangeMetadataItem import AdvancedCommerceSubscriptionChangeMetadataItem +from appstoreserverlibrary.models.AdvancedCommerceSubscriptionChangeMetadataRequest import AdvancedCommerceSubscriptionChangeMetadataRequest +from appstoreserverlibrary.models.AdvancedCommerceSubscriptionChangeMetadataResponse import AdvancedCommerceSubscriptionChangeMetadataResponse +from appstoreserverlibrary.models.AdvancedCommerceSubscriptionCreateItem import AdvancedCommerceSubscriptionCreateItem +from appstoreserverlibrary.models.AdvancedCommerceSubscriptionCreateRequest import AdvancedCommerceSubscriptionCreateRequest +from appstoreserverlibrary.models.AdvancedCommerceSubscriptionMigrateDescriptors import AdvancedCommerceSubscriptionMigrateDescriptors +from appstoreserverlibrary.models.AdvancedCommerceSubscriptionMigrateItem import AdvancedCommerceSubscriptionMigrateItem +from appstoreserverlibrary.models.AdvancedCommerceSubscriptionMigrateRenewalItem import AdvancedCommerceSubscriptionMigrateRenewalItem +from appstoreserverlibrary.models.AdvancedCommerceSubscriptionMigrateRequest import AdvancedCommerceSubscriptionMigrateRequest +from appstoreserverlibrary.models.AdvancedCommerceSubscriptionMigrateResponse import AdvancedCommerceSubscriptionMigrateResponse +from appstoreserverlibrary.models.AdvancedCommerceSubscriptionModifyAddItem import AdvancedCommerceSubscriptionModifyAddItem +from appstoreserverlibrary.models.AdvancedCommerceSubscriptionModifyChangeItem import AdvancedCommerceSubscriptionModifyChangeItem +from appstoreserverlibrary.models.AdvancedCommerceSubscriptionModifyDescriptors import AdvancedCommerceSubscriptionModifyDescriptors +from appstoreserverlibrary.models.AdvancedCommerceSubscriptionModifyInAppRequest import AdvancedCommerceSubscriptionModifyInAppRequest +from appstoreserverlibrary.models.AdvancedCommerceSubscriptionModifyPeriodChange import AdvancedCommerceSubscriptionModifyPeriodChange +from appstoreserverlibrary.models.AdvancedCommerceSubscriptionModifyRemoveItem import AdvancedCommerceSubscriptionModifyRemoveItem +from appstoreserverlibrary.models.AdvancedCommerceSubscriptionPriceChangeItem import AdvancedCommerceSubscriptionPriceChangeItem +from appstoreserverlibrary.models.AdvancedCommerceSubscriptionPriceChangeRequest import AdvancedCommerceSubscriptionPriceChangeRequest +from appstoreserverlibrary.models.AdvancedCommerceSubscriptionPriceChangeResponse import AdvancedCommerceSubscriptionPriceChangeResponse +from appstoreserverlibrary.models.AdvancedCommerceSubscriptionReactivateInAppRequest import AdvancedCommerceSubscriptionReactivateInAppRequest +from appstoreserverlibrary.models.AdvancedCommerceSubscriptionReactivateItem import AdvancedCommerceSubscriptionReactivateItem +from appstoreserverlibrary.models.AdvancedCommerceSubscriptionRevokeRequest import AdvancedCommerceSubscriptionRevokeRequest +from appstoreserverlibrary.models.AdvancedCommerceSubscriptionRevokeResponse import AdvancedCommerceSubscriptionRevokeResponse +from appstoreserverlibrary.models.AdvancedCommerceTransactionInfo import AdvancedCommerceTransactionInfo +from appstoreserverlibrary.models.AdvancedCommerceTransactionItem import AdvancedCommerceTransactionItem +from appstoreserverlibrary.models.HelperValidationUtils import HelperValidationUtils +from appstoreserverlibrary.models.AutoRenewStatus import AutoRenewStatus +from appstoreserverlibrary.models.BillingPlanType import BillingPlanType +from appstoreserverlibrary.models.RenewalBillingPlanType import RenewalBillingPlanType +from appstoreserverlibrary.models.RenewalCommitmentInfo import RenewalCommitmentInfo +from appstoreserverlibrary.models.TransactionCommitmentInfo import TransactionCommitmentInfo +from appstoreserverlibrary.models.LibraryUtility import _get_cattrs_converter +from tests.util import read_data_from_file + + +class AdvancedCommerceModelsTest(unittest.TestCase): + def test_advanced_commerce_period(self): + self.assertEqual("P1W", AdvancedCommercePeriod.P1W.value) + self.assertEqual("P1M", AdvancedCommercePeriod.P1M.value) + self.assertEqual("P2M", AdvancedCommercePeriod.P2M.value) + self.assertEqual("P3M", AdvancedCommercePeriod.P3M.value) + self.assertEqual("P6M", AdvancedCommercePeriod.P6M.value) + self.assertEqual("P1Y", AdvancedCommercePeriod.P1Y.value) + + self.assertEqual(AdvancedCommercePeriod.P1W, AdvancedCommercePeriod("P1W")) + self.assertEqual(AdvancedCommercePeriod.P1M, AdvancedCommercePeriod("P1M")) + self.assertEqual(AdvancedCommercePeriod.P1Y, AdvancedCommercePeriod("P1Y")) + self.assertFalse("INVALID" in AdvancedCommercePeriod) + + self.assertEqual("P1W", AdvancedCommercePeriod.P1W.value) + + def test_advanced_commerce_reason(self): + self.assertEqual("UPGRADE", AdvancedCommerceReason.UPGRADE.value) + self.assertEqual("DOWNGRADE", AdvancedCommerceReason.DOWNGRADE.value) + self.assertEqual("APPLY_OFFER", AdvancedCommerceReason.APPLY_OFFER.value) + + self.assertEqual(AdvancedCommerceReason.UPGRADE, AdvancedCommerceReason("UPGRADE")) + self.assertEqual(AdvancedCommerceReason.DOWNGRADE, AdvancedCommerceReason("DOWNGRADE")) + self.assertEqual(AdvancedCommerceReason.APPLY_OFFER, AdvancedCommerceReason("APPLY_OFFER")) + self.assertFalse("INVALID" in AdvancedCommerceReason) + + self.assertEqual("UPGRADE", AdvancedCommerceReason.UPGRADE.value) + + def test_advanced_commerce_refund_reason(self): + self.assertEqual("UNINTENDED_PURCHASE", AdvancedCommerceRefundReason.UNINTENDED_PURCHASE.value) + self.assertEqual("FULFILLMENT_ISSUE", AdvancedCommerceRefundReason.FULFILLMENT_ISSUE.value) + self.assertEqual("UNSATISFIED_WITH_PURCHASE", AdvancedCommerceRefundReason.UNSATISFIED_WITH_PURCHASE.value) + self.assertEqual("LEGAL", AdvancedCommerceRefundReason.LEGAL.value) + self.assertEqual("OTHER", AdvancedCommerceRefundReason.OTHER.value) + self.assertEqual("MODIFY_ITEMS_REFUND", AdvancedCommerceRefundReason.MODIFY_ITEMS_REFUND.value) + self.assertEqual("SIMULATE_REFUND_DECLINE", AdvancedCommerceRefundReason.SIMULATE_REFUND_DECLINE.value) + + self.assertEqual(AdvancedCommerceRefundReason.LEGAL, AdvancedCommerceRefundReason("LEGAL")) + self.assertEqual(AdvancedCommerceRefundReason.OTHER, AdvancedCommerceRefundReason("OTHER")) + self.assertFalse("INVALID" in AdvancedCommerceRefundReason) + + self.assertEqual("LEGAL", AdvancedCommerceRefundReason.LEGAL.value) + + def test_advanced_commerce_refund_type(self): + self.assertEqual("FULL", AdvancedCommerceRefundType.FULL.value) + self.assertEqual("PRORATED", AdvancedCommerceRefundType.PRORATED.value) + self.assertEqual("CUSTOM", AdvancedCommerceRefundType.CUSTOM.value) + + self.assertEqual(AdvancedCommerceRefundType.FULL, AdvancedCommerceRefundType("FULL")) + self.assertEqual(AdvancedCommerceRefundType.PRORATED, AdvancedCommerceRefundType("PRORATED")) + self.assertEqual(AdvancedCommerceRefundType.CUSTOM, AdvancedCommerceRefundType("CUSTOM")) + self.assertFalse("INVALID" in AdvancedCommerceRefundType) + + self.assertEqual("FULL", AdvancedCommerceRefundType.FULL.value) + + def test_advanced_commerce_offer_period(self): + self.assertEqual("P3D", AdvancedCommerceOfferPeriod.P3D.value) + self.assertEqual("P1W", AdvancedCommerceOfferPeriod.P1W.value) + self.assertEqual("P2W", AdvancedCommerceOfferPeriod.P2W.value) + self.assertEqual("P1M", AdvancedCommerceOfferPeriod.P1M.value) + self.assertEqual("P2M", AdvancedCommerceOfferPeriod.P2M.value) + self.assertEqual("P3M", AdvancedCommerceOfferPeriod.P3M.value) + + self.assertEqual(AdvancedCommerceOfferPeriod.P1W, AdvancedCommerceOfferPeriod("P1W")) + self.assertEqual(AdvancedCommerceOfferPeriod.P1M, AdvancedCommerceOfferPeriod("P1M")) + self.assertEqual(AdvancedCommerceOfferPeriod.P3D, AdvancedCommerceOfferPeriod("P3D")) + self.assertFalse("INVALID" in AdvancedCommerceOfferPeriod) + + self.assertEqual("P1W", AdvancedCommerceOfferPeriod.P1W.value) + + def test_advanced_commerce_offer_reason(self): + self.assertEqual("ACQUISITION", AdvancedCommerceOfferReason.ACQUISITION.value) + self.assertEqual("WIN_BACK", AdvancedCommerceOfferReason.WIN_BACK.value) + self.assertEqual("RETENTION", AdvancedCommerceOfferReason.RETENTION.value) + + self.assertEqual(AdvancedCommerceOfferReason.ACQUISITION, AdvancedCommerceOfferReason("ACQUISITION")) + self.assertEqual(AdvancedCommerceOfferReason.WIN_BACK, AdvancedCommerceOfferReason("WIN_BACK")) + self.assertEqual(AdvancedCommerceOfferReason.RETENTION, AdvancedCommerceOfferReason("RETENTION")) + self.assertFalse("INVALID" in AdvancedCommerceOfferReason) + + self.assertEqual("WIN_BACK", AdvancedCommerceOfferReason.WIN_BACK.value) + + def test_advanced_commerce_effective(self): + self.assertEqual("IMMEDIATELY", AdvancedCommerceEffective.IMMEDIATELY.value) + self.assertEqual("NEXT_BILL_CYCLE", AdvancedCommerceEffective.NEXT_BILL_CYCLE.value) + + self.assertEqual(AdvancedCommerceEffective.IMMEDIATELY, AdvancedCommerceEffective("IMMEDIATELY")) + self.assertEqual(AdvancedCommerceEffective.NEXT_BILL_CYCLE, AdvancedCommerceEffective("NEXT_BILL_CYCLE")) + self.assertFalse("INVALID" in AdvancedCommerceEffective) + + self.assertEqual("IMMEDIATELY", AdvancedCommerceEffective.IMMEDIATELY.value) + + def test_validation_utils_description(self): + valid_description = "Valid description" + HelperValidationUtils.description_validator(None, None, valid_description) + + max_length_description = "A" * 45 + HelperValidationUtils.description_validator(None, None, max_length_description) + + too_long_description = "A" * 46 + with self.assertRaises(ValueError): + HelperValidationUtils.description_validator(None, None, too_long_description) + + def test_validation_utils_display_name(self): + valid_display_name = "Valid Name" + HelperValidationUtils.display_name_validator(None, None, valid_display_name) + + max_length_display_name = "A" * 30 + HelperValidationUtils.display_name_validator(None, None, max_length_display_name) + + too_long_display_name = "A" * 31 + with self.assertRaises(ValueError): + HelperValidationUtils.display_name_validator(None, None, too_long_display_name) + + def test_validation_utils_sku(self): + valid_sku = "valid.sku.123" + HelperValidationUtils.sku_validator(None, None, valid_sku) + + max_length_sku = "A" * 128 + HelperValidationUtils.sku_validator(None, None, max_length_sku) + + too_long_sku = "A" * 129 + with self.assertRaises(ValueError): + HelperValidationUtils.sku_validator(None, None, too_long_sku) + + def test_validation_utils_period_count(self): + HelperValidationUtils.period_count_validator(None, None, 1) + HelperValidationUtils.period_count_validator(None, None, 6) + HelperValidationUtils.period_count_validator(None, None, 12) + + with self.assertRaises(ValueError): + HelperValidationUtils.period_count_validator(None, None, 0) + + with self.assertRaises(ValueError): + HelperValidationUtils.period_count_validator(None, None, 13) + + def test_validation_utils_items(self): + valid_list = [ + AdvancedCommerceOneTimeChargeItem( + description="desc", + displayName="name", + SKU="sku1", + price=1000 + ) + ] + HelperValidationUtils.items_validator(None, None, valid_list) + + with self.assertRaises(ValueError): + HelperValidationUtils.items_validator(None, None, None) + + with self.assertRaises(ValueError): + HelperValidationUtils.items_validator(None, None, []) + + list_with_none = [None] + with self.assertRaises(ValueError): + HelperValidationUtils.items_validator(None, None, list_with_none) + + def test_advanced_commerce_descriptors_deserialization(self): + json_data = read_data_from_file('tests/resources/models/advancedCommerceDescriptors.json') + + descriptors_dict = json.loads(json_data) + descriptors = _get_cattrs_converter(AdvancedCommerceDescriptors).structure(descriptors_dict, AdvancedCommerceDescriptors) + + self.assertEqual("description", descriptors.description) + self.assertEqual("display name", descriptors.displayName) + + def test_advanced_commerce_one_time_charge_item_deserialization(self): + json_data = read_data_from_file('tests/resources/models/advancedCommerceOneTimeChargeItem.json') + + item_dict = json.loads(json_data) + item = _get_cattrs_converter(AdvancedCommerceOneTimeChargeItem).structure(item_dict, AdvancedCommerceOneTimeChargeItem) + + self.assertEqual("description", item.description) + self.assertEqual("display name", item.displayName) + self.assertEqual("sku", item.SKU) + self.assertEqual(15000, item.price) + + def test_advanced_commerce_subscription_create_item_deserialization(self): + json_data = read_data_from_file('tests/resources/models/advancedCommerceSubscriptionCreateItem.json') + + item_dict = json.loads(json_data) + item = _get_cattrs_converter(AdvancedCommerceSubscriptionCreateItem).structure(item_dict, AdvancedCommerceSubscriptionCreateItem) + + self.assertEqual("description", item.description) + self.assertEqual("display name", item.displayName) + self.assertEqual("sku", item.SKU) + self.assertEqual(20000, item.price) + + def test_advanced_commerce_request_refund_item_deserialization(self): + json_data = read_data_from_file('tests/resources/models/advancedCommerceRequestRefundItem.json') + + item_dict = json.loads(json_data) + item = _get_cattrs_converter(AdvancedCommerceRequestRefundItem).structure(item_dict, AdvancedCommerceRequestRefundItem) + + self.assertEqual("sku", item.SKU) + self.assertEqual(AdvancedCommerceRefundReason.LEGAL, item.refundReason) + self.assertEqual("LEGAL", item.rawRefundReason) + self.assertEqual(AdvancedCommerceRefundType.FULL, item.refundType) + self.assertEqual("FULL", item.rawRefundType) + self.assertTrue(item.revoke) + self.assertEqual(5000, item.refundAmount) + + def test_advanced_commerce_offer_deserialization(self): + json_data = read_data_from_file('tests/resources/models/advancedCommerceOffer.json') + + offer_dict = json.loads(json_data) + offer = _get_cattrs_converter(AdvancedCommerceOffer).structure(offer_dict, AdvancedCommerceOffer) + + self.assertEqual(AdvancedCommerceOfferPeriod.P1W, offer.period) + self.assertEqual("P1W", offer.rawPeriod) + self.assertEqual(3, offer.periodCount) + self.assertEqual(5000, offer.price) + self.assertEqual(AdvancedCommerceOfferReason.WIN_BACK, offer.reason) + self.assertEqual("WIN_BACK", offer.rawReason) + + def test_advanced_commerce_one_time_charge_create_request_deserialization(self): + json_data = read_data_from_file('tests/resources/models/advancedCommerceOneTimeChargeCreateRequest.json') + + request_dict = json.loads(json_data) + request = _get_cattrs_converter(AdvancedCommerceOneTimeChargeCreateRequest).structure(request_dict, AdvancedCommerceOneTimeChargeCreateRequest) + + self.assertEqual("USD", request.currency) + self.assertIsNotNone(request.item) + self.assertEqual("description", request.item.description) + self.assertEqual("display name", request.item.displayName) + self.assertEqual("sku", request.item.SKU) + self.assertEqual(10000, request.item.price) + self.assertIsNotNone(request.requestInfo) + self.assertEqual("550e8400-e29b-41d4-a716-446655440000", str(request.requestInfo.requestReferenceId)) + self.assertEqual("taxCode", request.taxCode) + self.assertEqual("USA", request.storefront) + self.assertEqual("CREATE_ONE_TIME_CHARGE", request.operation) + self.assertEqual("1", request.version) + + def test_advanced_commerce_subscription_create_request_deserialization(self): + json_data = read_data_from_file('tests/resources/models/advancedCommerceSubscriptionCreateRequest.json') + + request_dict = json.loads(json_data) + request = _get_cattrs_converter(AdvancedCommerceSubscriptionCreateRequest).structure(request_dict, AdvancedCommerceSubscriptionCreateRequest) + + self.assertEqual("USD", request.currency) + self.assertIsNotNone(request.descriptors) + self.assertEqual("description", request.descriptors.description) + self.assertEqual("display name", request.descriptors.displayName) + self.assertEqual(2, len(request.items)) + self.assertEqual("sku", request.items[0].SKU) + self.assertEqual(20000, request.items[0].price) + self.assertEqual("sku", request.items[1].SKU) + self.assertEqual(30000, request.items[1].price) + self.assertEqual("P1M", request.period) + self.assertIsNotNone(request.requestInfo) + self.assertEqual("550e8400-e29b-41d4-a716-446655440001", str(request.requestInfo.requestReferenceId)) + self.assertEqual("taxCode", request.taxCode) + self.assertEqual("USA", request.storefront) + self.assertEqual("transactionId", request.previousTransactionId) + + def test_advanced_commerce_request_refund_request_deserialization(self): + json_data = read_data_from_file('tests/resources/models/advancedCommerceRequestRefundRequest.json') + + request_dict = json.loads(json_data) + request = _get_cattrs_converter(AdvancedCommerceRequestRefundRequest).structure(request_dict, AdvancedCommerceRequestRefundRequest) + + self.assertEqual(2, len(request.items)) + self.assertEqual("sku", request.items[0].SKU) + self.assertEqual(AdvancedCommerceRefundReason.LEGAL, request.items[0].refundReason) + self.assertEqual(AdvancedCommerceRefundType.FULL, request.items[0].refundType) + self.assertTrue(request.items[0].revoke) + self.assertEqual("sku", request.items[1].SKU) + self.assertEqual(AdvancedCommerceRefundReason.OTHER, request.items[1].refundReason) + self.assertEqual(AdvancedCommerceRefundType.PRORATED, request.items[1].refundType) + self.assertFalse(request.items[1].revoke) + self.assertTrue(request.refundRiskingPreference) + self.assertEqual("550e8400-e29b-41d4-a716-446655440002", str(request.requestInfo.requestReferenceId)) + self.assertEqual("USD", request.currency) + self.assertEqual("USA", request.storefront) + + def test_advanced_commerce_subscription_cancel_request_deserialization(self): + json_data = read_data_from_file('tests/resources/models/advancedCommerceSubscriptionCancelRequest.json') + + request_dict = json.loads(json_data) + request = _get_cattrs_converter(AdvancedCommerceSubscriptionCancelRequest).structure(request_dict, AdvancedCommerceSubscriptionCancelRequest) + + self.assertIsNotNone(request.requestInfo) + self.assertEqual("550e8400-e29b-41d4-a716-446655440003", str(request.requestInfo.requestReferenceId)) + self.assertEqual("USA", request.storefront) + + def test_advanced_commerce_subscription_revoke_request_deserialization(self): + json_data = read_data_from_file('tests/resources/models/advancedCommerceSubscriptionRevokeRequest.json') + + request_dict = json.loads(json_data) + request = _get_cattrs_converter(AdvancedCommerceSubscriptionRevokeRequest).structure(request_dict, AdvancedCommerceSubscriptionRevokeRequest) + + self.assertIsNotNone(request.requestInfo) + self.assertEqual("550e8400-e29b-41d4-a716-446655440004", str(request.requestInfo.requestReferenceId)) + self.assertTrue(request.refundRiskingPreference) + self.assertEqual("USA", request.storefront) + + def test_advanced_commerce_subscription_price_change_request_deserialization(self): + json_data = read_data_from_file('tests/resources/models/advancedCommerceSubscriptionPriceChangeRequest.json') + + request_dict = json.loads(json_data) + request = _get_cattrs_converter(AdvancedCommerceSubscriptionPriceChangeRequest).structure(request_dict, AdvancedCommerceSubscriptionPriceChangeRequest) + + self.assertIsNotNone(request.requestInfo) + self.assertEqual("550e8400-e29b-41d4-a716-446655440005", str(request.requestInfo.requestReferenceId)) + self.assertEqual(1, len(request.items)) + self.assertEqual("sku123", request.items[0].SKU) + self.assertEqual(15000, request.items[0].price) + self.assertEqual("USD", request.currency) + + def test_advanced_commerce_request_refund_response_deserialization(self): + json_data = read_data_from_file('tests/resources/models/advancedCommerceRequestRefundResponse.json') + + response_dict = json.loads(json_data) + response = _get_cattrs_converter(AdvancedCommerceRequestRefundResponse).structure(response_dict, AdvancedCommerceRequestRefundResponse) + + self.assertEqual("signed_transaction_info_value", response.signedTransactionInfo) + + def test_advanced_commerce_subscription_cancel_response_deserialization(self): + json_data = read_data_from_file('tests/resources/models/advancedCommerceSubscriptionCancelResponse.json') + + response_dict = json.loads(json_data) + response = _get_cattrs_converter(AdvancedCommerceSubscriptionCancelResponse).structure(response_dict, AdvancedCommerceSubscriptionCancelResponse) + + self.assertEqual("signed_renewal_info", response.signedRenewalInfo) + self.assertEqual("signed_transaction_info", response.signedTransactionInfo) + + def test_advanced_commerce_subscription_revoke_response_deserialization(self): + json_data = read_data_from_file('tests/resources/models/advancedCommerceSubscriptionRevokeResponse.json') + + response_dict = json.loads(json_data) + response = _get_cattrs_converter(AdvancedCommerceSubscriptionRevokeResponse).structure(response_dict, AdvancedCommerceSubscriptionRevokeResponse) + + self.assertEqual("signed_renewal_info", response.signedRenewalInfo) + self.assertEqual("signed_transaction_info", response.signedTransactionInfo) + + def test_advanced_commerce_subscription_price_change_response_deserialization(self): + json_data = read_data_from_file('tests/resources/models/advancedCommerceSubscriptionPriceChangeResponse.json') + + response_dict = json.loads(json_data) + response = _get_cattrs_converter(AdvancedCommerceSubscriptionPriceChangeResponse).structure(response_dict, AdvancedCommerceSubscriptionPriceChangeResponse) + + self.assertEqual("signed_renewal_info", response.signedRenewalInfo) + self.assertEqual("signed_transaction_info", response.signedTransactionInfo) + + def test_advanced_commerce_subscription_change_metadata_response_deserialization(self): + json_data = read_data_from_file('tests/resources/models/advancedCommerceSubscriptionChangeMetadataResponse.json') + + response_dict = json.loads(json_data) + response = _get_cattrs_converter(AdvancedCommerceSubscriptionChangeMetadataResponse).structure(response_dict, AdvancedCommerceSubscriptionChangeMetadataResponse) + + self.assertEqual("signed_renewal_info", response.signedRenewalInfo) + self.assertEqual("signed_transaction_info", response.signedTransactionInfo) + + + def test_advanced_commerce_subscription_migrate_request_deserialization(self): + json_data = read_data_from_file('tests/resources/models/advancedCommerceSubscriptionMigrateRequest.json') + + request_dict = json.loads(json_data) + request = _get_cattrs_converter(AdvancedCommerceSubscriptionMigrateRequest).structure(request_dict, AdvancedCommerceSubscriptionMigrateRequest) + + self.assertIsNotNone(request.requestInfo) + self.assertEqual("550e8400-e29b-41d4-a716-446655440006", str(request.requestInfo.requestReferenceId)) + self.assertIsNotNone(request.descriptors) + self.assertEqual("description", request.descriptors.description) + self.assertEqual("display name", request.descriptors.displayName) + self.assertEqual(1, len(request.items)) + self.assertEqual("sku", request.items[0].SKU) + self.assertEqual("targetProductId", request.targetProductId) + self.assertEqual("taxCode", request.taxCode) + + def test_advanced_commerce_subscription_modify_in_app_request_deserialization(self): + json_data = read_data_from_file('tests/resources/models/advancedCommerceSubscriptionModifyInAppRequest.json') + + request_dict = json.loads(json_data) + request = _get_cattrs_converter(AdvancedCommerceSubscriptionModifyInAppRequest).structure(request_dict, AdvancedCommerceSubscriptionModifyInAppRequest) + + self.assertIsNotNone(request.requestInfo) + self.assertEqual("550e8400-e29b-41d4-a716-446655440007", str(request.requestInfo.requestReferenceId)) + self.assertEqual("transactionId", request.transactionId) + self.assertTrue(request.retainBillingCycle) + self.assertIsNotNone(request.descriptors) + self.assertEqual("description", request.descriptors.description) + self.assertEqual("display name", request.descriptors.displayName) + self.assertEqual("taxCode", request.taxCode) + self.assertEqual("USD", request.currency) + + def test_advanced_commerce_subscription_reactivate_in_app_request_deserialization(self): + json_data = read_data_from_file('tests/resources/models/advancedCommerceSubscriptionReactivateInAppRequest.json') + + request_dict = json.loads(json_data) + request = _get_cattrs_converter(AdvancedCommerceSubscriptionReactivateInAppRequest).structure(request_dict, AdvancedCommerceSubscriptionReactivateInAppRequest) + + self.assertIsNotNone(request.requestInfo) + self.assertEqual("550e8400-e29b-41d4-a716-446655440008", str(request.requestInfo.requestReferenceId)) + self.assertEqual("transactionId", request.transactionId) + self.assertEqual(1, len(request.items)) + self.assertEqual("sku", request.items[0].SKU) + + def test_advanced_commerce_subscription_change_metadata_request_deserialization(self): + json_data = read_data_from_file('tests/resources/models/advancedCommerceSubscriptionChangeMetadataRequest.json') + + request_dict = json.loads(json_data) + request = _get_cattrs_converter(AdvancedCommerceSubscriptionChangeMetadataRequest).structure(request_dict, AdvancedCommerceSubscriptionChangeMetadataRequest) + + self.assertIsNotNone(request.requestInfo) + self.assertEqual("550e8400-e29b-41d4-a716-446655440009", str(request.requestInfo.requestReferenceId)) + self.assertEqual(1, len(request.items)) + self.assertEqual("currentSKU", request.items[0].currentSKU) + self.assertEqual("sku", request.items[0].SKU) + + def test_advanced_commerce_subscription_migrate_descriptors_deserialization(self): + json_data = read_data_from_file('tests/resources/models/advancedCommerceSubscriptionMigrateDescriptors.json') + + descriptors_dict = json.loads(json_data) + descriptors = _get_cattrs_converter(AdvancedCommerceSubscriptionMigrateDescriptors).structure(descriptors_dict, AdvancedCommerceSubscriptionMigrateDescriptors) + + self.assertEqual("description", descriptors.description) + self.assertEqual("displayName", descriptors.displayName) + + def test_advanced_commerce_subscription_modify_descriptors_deserialization(self): + json_data = read_data_from_file('tests/resources/models/advancedCommerceSubscriptionModifyDescriptors.json') + + descriptors_dict = json.loads(json_data) + descriptors = _get_cattrs_converter(AdvancedCommerceSubscriptionModifyDescriptors).structure(descriptors_dict, AdvancedCommerceSubscriptionModifyDescriptors) + + self.assertEqual("description", descriptors.description) + self.assertEqual("displayName", descriptors.displayName) + + def test_advanced_commerce_subscription_change_metadata_descriptors_deserialization(self): + json_data = read_data_from_file('tests/resources/models/advancedCommerceSubscriptionChangeMetadataDescriptors.json') + + descriptors_dict = json.loads(json_data) + descriptors = _get_cattrs_converter(AdvancedCommerceSubscriptionChangeMetadataDescriptors).structure(descriptors_dict, AdvancedCommerceSubscriptionChangeMetadataDescriptors) + + self.assertEqual("description", descriptors.description) + self.assertEqual("displayName", descriptors.displayName) + + def test_advanced_commerce_subscription_change_metadata_item_deserialization(self): + json_data = read_data_from_file('tests/resources/models/advancedCommerceSubscriptionChangeMetadataItem.json') + + item_dict = json.loads(json_data) + item = _get_cattrs_converter(AdvancedCommerceSubscriptionChangeMetadataItem).structure(item_dict, AdvancedCommerceSubscriptionChangeMetadataItem) + + self.assertEqual("currentSku", item.currentSKU) + self.assertEqual("sku", item.SKU) + self.assertEqual("description", item.description) + self.assertEqual("displayName", item.displayName) + + def test_advanced_commerce_subscription_migrate_renewal_item_deserialization(self): + json_data = read_data_from_file('tests/resources/models/advancedCommerceSubscriptionMigrateRenewalItem.json') + + item_dict = json.loads(json_data) + item = _get_cattrs_converter(AdvancedCommerceSubscriptionMigrateRenewalItem).structure(item_dict, AdvancedCommerceSubscriptionMigrateRenewalItem) + + self.assertEqual("sku", item.SKU) + self.assertEqual("description", item.description) + self.assertEqual("displayName", item.displayName) + + def test_advanced_commerce_subscription_modify_add_item_deserialization(self): + json_data = read_data_from_file('tests/resources/models/advancedCommerceSubscriptionModifyAddItem.json') + + item_dict = json.loads(json_data) + item = _get_cattrs_converter(AdvancedCommerceSubscriptionModifyAddItem).structure(item_dict, AdvancedCommerceSubscriptionModifyAddItem) + + self.assertEqual("sku", item.SKU) + self.assertEqual("description", item.description) + self.assertEqual("displayName", item.displayName) + self.assertEqual(12000, item.price) + + def test_advanced_commerce_subscription_modify_change_item_deserialization(self): + json_data = read_data_from_file('tests/resources/models/advancedCommerceSubscriptionModifyChangeItem.json') + + item_dict = json.loads(json_data) + item = _get_cattrs_converter(AdvancedCommerceSubscriptionModifyChangeItem).structure(item_dict, AdvancedCommerceSubscriptionModifyChangeItem) + + self.assertEqual("currentSku", item.currentSKU) + self.assertEqual("sku", item.SKU) + self.assertEqual("description", item.description) + self.assertEqual("displayName", item.displayName) + self.assertEqual(13000, item.price) + + def test_advanced_commerce_subscription_modify_remove_item_deserialization(self): + json_data = read_data_from_file('tests/resources/models/advancedCommerceSubscriptionModifyRemoveItem.json') + + item_dict = json.loads(json_data) + item = _get_cattrs_converter(AdvancedCommerceSubscriptionModifyRemoveItem).structure(item_dict, AdvancedCommerceSubscriptionModifyRemoveItem) + + self.assertEqual("sku", item.SKU) + + def test_advanced_commerce_subscription_modify_period_change_deserialization(self): + json_data = read_data_from_file('tests/resources/models/advancedCommerceSubscriptionModifyPeriodChange.json') + + change_dict = json.loads(json_data) + change = _get_cattrs_converter(AdvancedCommerceSubscriptionModifyPeriodChange).structure(change_dict, AdvancedCommerceSubscriptionModifyPeriodChange) + + self.assertEqual("P3M", change.period) + + def test_advanced_commerce_subscription_price_change_item_deserialization(self): + json_data = read_data_from_file('tests/resources/models/advancedCommerceSubscriptionPriceChangeItem.json') + + item_dict = json.loads(json_data) + item = _get_cattrs_converter(AdvancedCommerceSubscriptionPriceChangeItem).structure(item_dict, AdvancedCommerceSubscriptionPriceChangeItem) + + self.assertEqual("sku", item.SKU) + self.assertEqual(16000, item.price) + self.assertEqual("dependentSKU", item.dependentSKUs[0]) + + def test_advanced_commerce_subscription_price_change_item_dependent_sku_validation(self): + valid_sku = "A" * 128 + too_long_sku = "A" * 129 + + # Valid SKU in dependentSKUs is accepted + item = AdvancedCommerceSubscriptionPriceChangeItem(SKU="sku", price=1000, dependentSKUs=[valid_sku]) + self.assertEqual(valid_sku, item.dependentSKUs[0]) + + # Too-long SKU in dependentSKUs raises ValueError + with self.assertRaises(ValueError): + AdvancedCommerceSubscriptionPriceChangeItem(SKU="sku", price=1000, dependentSKUs=[too_long_sku]) + + # None list is allowed (field is optional) + item_none = AdvancedCommerceSubscriptionPriceChangeItem(SKU="sku", price=1000, dependentSKUs=None) + self.assertIsNone(item_none.dependentSKUs) + + def test_advanced_commerce_subscription_reactivate_item_deserialization(self): + json_data = read_data_from_file('tests/resources/models/advancedCommerceSubscriptionReactivateItem.json') + + item_dict = json.loads(json_data) + item = _get_cattrs_converter(AdvancedCommerceSubscriptionReactivateItem).structure(item_dict, AdvancedCommerceSubscriptionReactivateItem) + + self.assertEqual("sku", item.SKU) + + def test_advanced_commerce_request_info_deserialization(self): + json_data = read_data_from_file('tests/resources/models/advancedCommerceRequestInfo.json') + + info_dict = json.loads(json_data) + info = _get_cattrs_converter(AdvancedCommerceRequestInfo).structure(info_dict, AdvancedCommerceRequestInfo) + + self.assertEqual("550e8400-e29b-41d4-a716-446655440010", str(info.requestReferenceId)) + self.assertEqual("660e8400-e29b-41d4-a716-446655440011", str(info.appAccountToken)) + self.assertEqual("consistency_token_value", info.consistencyToken) + + def test_advanced_commerce_subscription_migrate_item_deserialization(self): + json_data = read_data_from_file('tests/resources/models/advancedCommerceSubscriptionMigrateItem.json') + + item_dict = json.loads(json_data) + item = _get_cattrs_converter(AdvancedCommerceSubscriptionMigrateItem).structure(item_dict, AdvancedCommerceSubscriptionMigrateItem) + + self.assertEqual("sku", item.SKU) + self.assertEqual("description", item.description) + self.assertEqual("displayName", item.displayName) + + def test_advanced_commerce_subscription_migrate_response_deserialization(self): + json_data = read_data_from_file('tests/resources/models/advancedCommerceSubscriptionMigrateResponse.json') + + response_dict = json.loads(json_data) + response = _get_cattrs_converter(AdvancedCommerceSubscriptionMigrateResponse).structure(response_dict, AdvancedCommerceSubscriptionMigrateResponse) + + self.assertEqual("signed_renewal_info_value", response.signedRenewalInfo) + self.assertEqual("signed_transaction_info_value", response.signedTransactionInfo) + + def test_one_time_charge_create_request_deserialization_sets_operation_and_version(self): + json_data = read_data_from_file('tests/resources/models/advancedCommerceOneTimeChargeCreateRequest.json') + + request_dict = json.loads(json_data) + request = _get_cattrs_converter(AdvancedCommerceOneTimeChargeCreateRequest).structure(request_dict, AdvancedCommerceOneTimeChargeCreateRequest) + + result = _get_cattrs_converter(AdvancedCommerceOneTimeChargeCreateRequest).unstructure(request) + self.assertEqual("CREATE_ONE_TIME_CHARGE", result["operation"]) + self.assertEqual("1", result["version"]) + + def test_subscription_create_request_deserialization_sets_operation_and_version(self): + json_data = read_data_from_file('tests/resources/models/advancedCommerceSubscriptionCreateRequest.json') + + request_dict = json.loads(json_data) + request = _get_cattrs_converter(AdvancedCommerceSubscriptionCreateRequest).structure(request_dict, AdvancedCommerceSubscriptionCreateRequest) + + result = _get_cattrs_converter(AdvancedCommerceSubscriptionCreateRequest).unstructure(request) + self.assertEqual("CREATE_SUBSCRIPTION", result["operation"]) + self.assertEqual("1", result["version"]) + + def test_subscription_modify_in_app_request_deserialization_sets_operation_and_version(self): + json_data = read_data_from_file('tests/resources/models/advancedCommerceSubscriptionModifyInAppRequest.json') + + request_dict = json.loads(json_data) + request = _get_cattrs_converter(AdvancedCommerceSubscriptionModifyInAppRequest).structure(request_dict, AdvancedCommerceSubscriptionModifyInAppRequest) + + result = _get_cattrs_converter(AdvancedCommerceSubscriptionModifyInAppRequest).unstructure(request) + self.assertEqual("MODIFY_SUBSCRIPTION", result["operation"]) + self.assertEqual("1", result["version"]) + + def test_subscription_reactivate_in_app_request_deserialization_sets_operation_and_version(self): + json_data = read_data_from_file('tests/resources/models/advancedCommerceSubscriptionReactivateInAppRequest.json') + + request_dict = json.loads(json_data) + request = _get_cattrs_converter(AdvancedCommerceSubscriptionReactivateInAppRequest).structure(request_dict, AdvancedCommerceSubscriptionReactivateInAppRequest) + + result = _get_cattrs_converter(AdvancedCommerceSubscriptionReactivateInAppRequest).unstructure(request) + self.assertEqual("REACTIVATE_SUBSCRIPTION", result["operation"]) + self.assertEqual("1", result["version"]) + + def test_advanced_commerce_price_increase_info_status(self): + self.assertEqual("SCHEDULED", AdvancedCommercePriceIncreaseInfoStatus.SCHEDULED.value) + self.assertEqual("PENDING", AdvancedCommercePriceIncreaseInfoStatus.PENDING.value) + self.assertEqual("ACCEPTED", AdvancedCommercePriceIncreaseInfoStatus.ACCEPTED.value) + + self.assertEqual(AdvancedCommercePriceIncreaseInfoStatus.SCHEDULED, + AdvancedCommercePriceIncreaseInfoStatus("SCHEDULED")) + self.assertEqual(AdvancedCommercePriceIncreaseInfoStatus.PENDING, + AdvancedCommercePriceIncreaseInfoStatus("PENDING")) + self.assertEqual(AdvancedCommercePriceIncreaseInfoStatus.ACCEPTED, + AdvancedCommercePriceIncreaseInfoStatus("ACCEPTED")) + self.assertFalse("INVALID" in AdvancedCommercePriceIncreaseInfoStatus) + + self.assertEqual("SCHEDULED", AdvancedCommercePriceIncreaseInfoStatus.SCHEDULED.value) + + def test_billing_plan_type(self): + self.assertEqual("BILLED_UPFRONT", BillingPlanType.BILLED_UPFRONT.value) + self.assertEqual("MONTHLY", BillingPlanType.MONTHLY.value) + + self.assertEqual(BillingPlanType.BILLED_UPFRONT, + BillingPlanType("BILLED_UPFRONT")) + self.assertEqual(BillingPlanType.MONTHLY, + BillingPlanType("MONTHLY")) + self.assertFalse("INVALID" in BillingPlanType) + + self.assertEqual("MONTHLY", BillingPlanType.MONTHLY.value) + + def test_renewal_billing_plan_type(self): + self.assertEqual("BILLED_UPFRONT", RenewalBillingPlanType.BILLED_UPFRONT.value) + self.assertEqual("MONTHLY", RenewalBillingPlanType.MONTHLY.value) + + self.assertEqual(RenewalBillingPlanType.BILLED_UPFRONT, + RenewalBillingPlanType("BILLED_UPFRONT")) + self.assertEqual(RenewalBillingPlanType.MONTHLY, + RenewalBillingPlanType("MONTHLY")) + self.assertFalse("INVALID" in RenewalBillingPlanType) + + self.assertEqual("BILLED_UPFRONT", RenewalBillingPlanType.BILLED_UPFRONT.value) + + def test_transaction_commitment_info_billing_period_number_validation(self): + info = TransactionCommitmentInfo(billingPeriodNumber=1) + self.assertEqual(1, info.billingPeriodNumber) + + info = TransactionCommitmentInfo(billingPeriodNumber=12) + self.assertEqual(12, info.billingPeriodNumber) + + info = TransactionCommitmentInfo(billingPeriodNumber=None) + self.assertIsNone(info.billingPeriodNumber) diff --git a/tests/test_api_client.py b/tests/test_api_client.py index 876b6ec0..f3f9061e 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -4,12 +4,14 @@ import unittest from requests import Response -from appstoreserverlibrary.api_client import APIError, APIException, AppStoreServerAPIClient +from appstoreserverlibrary.api_client import APIError, APIException, AppStoreServerAPIClient, GetTransactionHistoryVersion from appstoreserverlibrary.models.AccountTenure import AccountTenure from appstoreserverlibrary.models.AutoRenewStatus import AutoRenewStatus from appstoreserverlibrary.models.ConsumptionRequest import ConsumptionRequest +from appstoreserverlibrary.models.ConsumptionRequestV1 import ConsumptionRequestV1 from appstoreserverlibrary.models.ConsumptionStatus import ConsumptionStatus from appstoreserverlibrary.models.DeliveryStatus import DeliveryStatus +from appstoreserverlibrary.models.DeliveryStatusV1 import DeliveryStatusV1 from appstoreserverlibrary.models.Environment import Environment from appstoreserverlibrary.models.ExpirationIntent import ExpirationIntent from appstoreserverlibrary.models.ExtendReasonCode import ExtendReasonCode @@ -26,17 +28,28 @@ from appstoreserverlibrary.models.OrderLookupStatus import OrderLookupStatus from appstoreserverlibrary.models.Platform import Platform from appstoreserverlibrary.models.PlayTime import PlayTime -from appstoreserverlibrary.models.PriceIncreaseStatus import PriceIncreaseStatus -from appstoreserverlibrary.models.RevocationReason import RevocationReason +from appstoreserverlibrary.models.RefundPreference import RefundPreference +from appstoreserverlibrary.models.RefundPreferenceV1 import RefundPreferenceV1 from appstoreserverlibrary.models.SendAttemptItem import SendAttemptItem from appstoreserverlibrary.models.SendAttemptResult import SendAttemptResult from appstoreserverlibrary.models.Status import Status from appstoreserverlibrary.models.SubscriptionGroupIdentifierItem import SubscriptionGroupIdentifierItem from appstoreserverlibrary.models.Subtype import Subtype from appstoreserverlibrary.models.TransactionHistoryRequest import Order, ProductType, TransactionHistoryRequest -from appstoreserverlibrary.models.TransactionReason import TransactionReason -from appstoreserverlibrary.models.Type import Type +from appstoreserverlibrary.models.UpdateAppAccountTokenRequest import UpdateAppAccountTokenRequest from appstoreserverlibrary.models.UserStatus import UserStatus +from appstoreserverlibrary.models.DefaultConfigurationRequest import DefaultConfigurationRequest +from appstoreserverlibrary.models.HeaderPosition import HeaderPosition +from appstoreserverlibrary.models.ImageSize import ImageSize +from appstoreserverlibrary.models.ImageState import ImageState +from appstoreserverlibrary.models.MessageState import MessageState +from appstoreserverlibrary.models.BulletPoint import BulletPoint +from appstoreserverlibrary.models.PerformanceTestRequest import PerformanceTestRequest +from appstoreserverlibrary.models.PerformanceTestStatus import PerformanceTestStatus +from appstoreserverlibrary.models.RealtimeUrlRequest import RealtimeUrlRequest +from appstoreserverlibrary.models.UploadMessageImage import UploadMessageImage +from appstoreserverlibrary.models.UploadMessageRequestBody import UploadMessageRequestBody +from uuid import UUID from tests.util import decode_json_from_signed_date, read_data_from_binary_file, read_data_from_file @@ -46,7 +59,7 @@ class DecodedPayloads(unittest.TestCase): def test_extend_renewal_date_for_all_active_subscribers(self): client = self.get_client_with_body_from_file('tests/resources/models/extendRenewalDateForAllActiveSubscribersResponse.json', 'POST', - 'https://api.storekit-sandbox.itunes.apple.com/inApps/v1/subscriptions/extend/mass', + 'https://local-testing-base-url/inApps/v1/subscriptions/extend/mass', {}, {'extendByDays': 45, 'extendReasonCode': 1, 'requestIdentifier': 'fdf964a4-233b-486c-aac1-97d8d52688ac', 'storefrontCountryCodes': ['USA', 'MEX'], 'productId': 'com.example.productId'}) @@ -65,7 +78,7 @@ def test_extend_renewal_date_for_all_active_subscribers(self): def test_extend_subscription_renewal_date(self): client = self.get_client_with_body_from_file('tests/resources/models/extendSubscriptionRenewalDateResponse.json', 'PUT', - 'https://api.storekit-sandbox.itunes.apple.com/inApps/v1/subscriptions/extend/4124214', + 'https://local-testing-base-url/inApps/v1/subscriptions/extend/4124214', {}, {'extendByDays': 45, 'extendReasonCode': 1, 'requestIdentifier': 'fdf964a4-233b-486c-aac1-97d8d52688ac'}) @@ -86,7 +99,7 @@ def test_extend_subscription_renewal_date(self): def test_get_all_subscription_statuses(self): client = self.get_client_with_body_from_file('tests/resources/models/getAllSubscriptionStatusesResponse.json', 'GET', - 'https://api.storekit-sandbox.itunes.apple.com/inApps/v1/subscriptions/4321', + 'https://local-testing-base-url/inApps/v1/subscriptions/4321', {'status': [2, 1]}, None) @@ -134,7 +147,7 @@ def test_get_all_subscription_statuses(self): def test_get_refund_history(self): client = self.get_client_with_body_from_file('tests/resources/models/getRefundHistoryResponse.json', 'GET', - 'https://api.storekit-sandbox.itunes.apple.com/inApps/v2/refund/lookup/555555', + 'https://local-testing-base-url/inApps/v2/refund/lookup/555555', {'revision': ['revision_input']}, None) @@ -148,7 +161,7 @@ def test_get_refund_history(self): def test_get_status_of_subscription_renewal_date_extensions(self): client = self.get_client_with_body_from_file('tests/resources/models/getStatusOfSubscriptionRenewalDateExtensionsResponse.json', 'GET', - 'https://api.storekit-sandbox.itunes.apple.com/inApps/v1/subscriptions/extend/mass/20fba8a0-2b80-4a7d-a17f-85c1854727f8/com.example.product', + 'https://local-testing-base-url/inApps/v1/subscriptions/extend/mass/20fba8a0-2b80-4a7d-a17f-85c1854727f8/com.example.product', {}, None) @@ -164,7 +177,7 @@ def test_get_status_of_subscription_renewal_date_extensions(self): def test_get_test_notification_status(self): client = self.get_client_with_body_from_file('tests/resources/models/getTestNotificationStatusResponse.json', 'GET', - 'https://api.storekit-sandbox.itunes.apple.com/inApps/v1/notifications/test/8cd2974c-f905-492a-bf9a-b2f47c791d19', + 'https://local-testing-base-url/inApps/v1/notifications/test/8cd2974c-f905-492a-bf9a-b2f47c791d19', {}, None) @@ -181,7 +194,7 @@ def test_get_test_notification_status(self): def test_get_notification_history(self): client = self.get_client_with_body_from_file('tests/resources/models/getNotificationHistoryResponse.json', 'POST', - 'https://api.storekit-sandbox.itunes.apple.com/inApps/v1/notifications/history', + 'https://local-testing-base-url/inApps/v1/notifications/history', {'paginationToken': ['a036bc0e-52b8-4bee-82fc-8c24cb6715d6']}, {'startDate': 1698148900000, 'endDate': 1698148950000, 'notificationType': 'SUBSCRIBED', 'notificationSubtype': 'INITIAL_BUY', 'transactionId': '999733843', 'onlyFailures': True}) @@ -219,10 +232,10 @@ def test_get_notification_history(self): ] self.assertEqual(expected_notification_history, notification_history_response.notificationHistory) - def test_get_transaction_history(self): + def test_get_transaction_history_v1(self): client = self.get_client_with_body_from_file('tests/resources/models/transactionHistoryResponse.json', 'GET', - 'https://api.storekit-sandbox.itunes.apple.com/inApps/v1/history/1234', + 'https://local-testing-base-url/inApps/v1/history/1234', {'revision': ['revision_input'], 'startDate': ['123455'], 'endDate': ['123456'], @@ -245,7 +258,44 @@ def test_get_transaction_history(self): subscriptionGroupIdentifiers=['sub_group_id', 'sub_group_id_2'] ) - history_response = client.get_transaction_history('1234', 'revision_input', request) + history_response = client.get_transaction_history('1234', 'revision_input', request, GetTransactionHistoryVersion.V1) + + self.assertIsNotNone(history_response) + self.assertEqual('revision_output', history_response.revision) + self.assertTrue(history_response.hasMore) + self.assertEqual('com.example', history_response.bundleId) + self.assertEqual(323232, history_response.appAppleId) + self.assertEqual(Environment.LOCAL_TESTING, history_response.environment) + self.assertEqual('LocalTesting', history_response.rawEnvironment) + self.assertEqual(['signed_transaction_value', 'signed_transaction_value2'], history_response.signedTransactions) + + def test_get_transaction_history_v2(self): + client = self.get_client_with_body_from_file('tests/resources/models/transactionHistoryResponse.json', + 'GET', + 'https://local-testing-base-url/inApps/v2/history/1234', + {'revision': ['revision_input'], + 'startDate': ['123455'], + 'endDate': ['123456'], + 'productId': ['com.example.1', 'com.example.2'], + 'productType': ['CONSUMABLE', 'AUTO_RENEWABLE'], + 'sort': ['ASCENDING'], + 'subscriptionGroupIdentifier': ['sub_group_id', 'sub_group_id_2'], + 'inAppOwnershipType': ['FAMILY_SHARED'], + 'revoked': ['False']}, + None) + + request = TransactionHistoryRequest( + sort=Order.ASCENDING, + productTypes=[ProductType.CONSUMABLE, ProductType.AUTO_RENEWABLE], + endDate=123456, + startDate=123455, + revoked=False, + inAppOwnershipType=InAppOwnershipType.FAMILY_SHARED, + productIds=['com.example.1', 'com.example.2'], + subscriptionGroupIdentifiers=['sub_group_id', 'sub_group_id_2'] + ) + + history_response = client.get_transaction_history('1234', 'revision_input', request, GetTransactionHistoryVersion.V2) self.assertIsNotNone(history_response) self.assertEqual('revision_output', history_response.revision) @@ -259,7 +309,7 @@ def test_get_transaction_history(self): def test_get_transaction_info(self): client = self.get_client_with_body_from_file('tests/resources/models/transactionInfoResponse.json', 'GET', - 'https://api.storekit-sandbox.itunes.apple.com/inApps/v1/transactions/1234', + 'https://local-testing-base-url/inApps/v1/transactions/1234', {}, None) @@ -271,7 +321,7 @@ def test_get_transaction_info(self): def test_look_up_order_id(self): client = self.get_client_with_body_from_file('tests/resources/models/lookupOrderIdResponse.json', 'GET', - 'https://api.storekit-sandbox.itunes.apple.com/inApps/v1/lookup/W002182', + 'https://local-testing-base-url/inApps/v1/lookup/W002182', {}, None) @@ -285,7 +335,7 @@ def test_look_up_order_id(self): def test_request_test_notification(self): client = self.get_client_with_body_from_file('tests/resources/models/requestTestNotificationResponse.json', 'POST', - 'https://api.storekit-sandbox.itunes.apple.com/inApps/v1/notifications/test', + 'https://local-testing-base-url/inApps/v1/notifications/test', {}, None) @@ -297,7 +347,7 @@ def test_request_test_notification(self): def test_send_consumption_data(self): client = self.get_client_with_body(b'', 'PUT', - 'https://api.storekit-sandbox.itunes.apple.com/inApps/v1/transactions/consumption/49571273', + 'https://local-testing-base-url/inApps/v1/transactions/consumption/49571273', {}, {'customerConsented': True, 'consumptionStatus': 1, @@ -309,28 +359,52 @@ def test_send_consumption_data(self): 'playTime': 5, 'lifetimeDollarsRefunded': 6, 'lifetimeDollarsPurchased': 7, - 'userStatus': 4}) + 'userStatus': 4, + 'refundPreference': 3}) - consumptionRequest = ConsumptionRequest( + consumptionRequest = ConsumptionRequestV1( customerConsented=True, consumptionStatus=ConsumptionStatus.NOT_CONSUMED, platform=Platform.NON_APPLE, sampleContentProvided=False, - deliveryStatus=DeliveryStatus.DID_NOT_DELIVER_DUE_TO_SERVER_OUTAGE, + deliveryStatus=DeliveryStatusV1.DID_NOT_DELIVER_DUE_TO_SERVER_OUTAGE, appAccountToken='7389a31a-fb6d-4569-a2a6-db7d85d84813', accountTenure=AccountTenure.THIRTY_DAYS_TO_NINETY_DAYS, playTime=PlayTime.ONE_DAY_TO_FOUR_DAYS, lifetimeDollarsRefunded=LifetimeDollarsRefunded.ONE_THOUSAND_DOLLARS_TO_ONE_THOUSAND_NINE_HUNDRED_NINETY_NINE_DOLLARS_AND_NINETY_NINE_CENTS, lifetimeDollarsPurchased=LifetimeDollarsPurchased.TWO_THOUSAND_DOLLARS_OR_GREATER, - userStatus=UserStatus.LIMITED_ACCESS + userStatus=UserStatus.LIMITED_ACCESS, + refundPreference=RefundPreferenceV1.NO_PREFERENCE ) client.send_consumption_data('49571273', consumptionRequest) + def test_send_consumption_information(self): + client = self.get_client_with_body(b'', + 'PUT', + 'https://local-testing-base-url/inApps/v2/transactions/consumption/49571273', + {}, + { + 'customerConsented': True, + 'sampleContentProvided': False, + 'deliveryStatus': 'DELIVERED', + 'consumptionPercentage': 50000, + 'refundPreference': 'GRANT_FULL' + }) + consumptionRequest = ConsumptionRequest( + customerConsented=True, + sampleContentProvided=False, + deliveryStatus=DeliveryStatus.DELIVERED, + consumptionPercentage=50000, + refundPreference=RefundPreference.GRANT_FULL + ) + + client.send_consumption_information('49571273', consumptionRequest) + def test_api_error(self): client = self.get_client_with_body_from_file('tests/resources/models/apiException.json', 'POST', - 'https://api.storekit-sandbox.itunes.apple.com/inApps/v1/notifications/test', + 'https://local-testing-base-url/inApps/v1/notifications/test', {}, None, 500) @@ -340,14 +414,25 @@ def test_api_error(self): self.assertEqual(500, e.http_status_code) self.assertEqual(5000000, e.raw_api_error) self.assertEqual(APIError.GENERAL_INTERNAL, e.api_error) + self.assertEqual("An unknown error occurred.", e.error_message) return self.assertFalse(True) + + def test_xcode_not_supported_error(self): + try: + signing_key = self.get_signing_key() + AppStoreServerAPIClient(signing_key, 'keyId', 'issuerId', 'com.example', Environment.XCODE) + except ValueError as e: + self.assertEqual("Xcode is not a supported environment for an AppStoreServerAPIClient", e.args[0]) + return + + self.assertFalse(True) def test_api_too_many_requests(self): client = self.get_client_with_body_from_file('tests/resources/models/apiTooManyRequestsException.json', 'POST', - 'https://api.storekit-sandbox.itunes.apple.com/inApps/v1/notifications/test', + 'https://local-testing-base-url/inApps/v1/notifications/test', {}, None, 429) @@ -357,6 +442,7 @@ def test_api_too_many_requests(self): self.assertEqual(429, e.http_status_code) self.assertEqual(4290000, e.raw_api_error) self.assertEqual(APIError.RATE_LIMIT_EXCEEDED, e.api_error) + self.assertEqual("Rate limit exceeded.", e.error_message) return self.assertFalse(True) @@ -364,7 +450,7 @@ def test_api_too_many_requests(self): def test_unknown_error(self): client = self.get_client_with_body_from_file('tests/resources/models/apiUnknownError.json', 'POST', - 'https://api.storekit-sandbox.itunes.apple.com/inApps/v1/notifications/test', + 'https://local-testing-base-url/inApps/v1/notifications/test', {}, None, 400) @@ -374,27 +460,442 @@ def test_unknown_error(self): self.assertEqual(400, e.http_status_code) self.assertEqual(9990000, e.raw_api_error) self.assertIsNone(e.api_error) + self.assertEqual("Testing error.", e.error_message) return self.assertFalse(True) - - def get_client_with_body(self, body: str, expected_method: str, expected_url: str, expected_params: Dict[str, Union[str, List[str]]], expected_json: Dict[str, Any], status_code: int = 200): - signing_key = read_data_from_binary_file('tests/resources/certs/testSigningKey.p8') + + def test_get_transaction_history_with_unknown_environment(self): + client = self.get_client_with_body_from_file('tests/resources/models/transactionHistoryResponseWithMalformedEnvironment.json', + 'GET', + 'https://local-testing-base-url/inApps/v2/history/1234', + {'revision': ['revision_input'], + 'startDate': ['123455'], + 'endDate': ['123456'], + 'productId': ['com.example.1', 'com.example.2'], + 'productType': ['CONSUMABLE', 'AUTO_RENEWABLE'], + 'sort': ['ASCENDING'], + 'subscriptionGroupIdentifier': ['sub_group_id', 'sub_group_id_2'], + 'inAppOwnershipType': ['FAMILY_SHARED'], + 'revoked': ['False']}, + None) + + request = TransactionHistoryRequest( + sort=Order.ASCENDING, + productTypes=[ProductType.CONSUMABLE, ProductType.AUTO_RENEWABLE], + endDate=123456, + startDate=123455, + revoked=False, + inAppOwnershipType=InAppOwnershipType.FAMILY_SHARED, + productIds=['com.example.1', 'com.example.2'], + subscriptionGroupIdentifiers=['sub_group_id', 'sub_group_id_2'] + ) + + history_response = client.get_transaction_history('1234', 'revision_input', request, GetTransactionHistoryVersion.V2) + + self.assertIsNone(history_response.environment) + self.assertEqual("LocalTestingxxx", history_response.rawEnvironment) + + def test_get_transaction_history_with_malformed_app_apple_id(self): + client = self.get_client_with_body_from_file('tests/resources/models/transactionHistoryResponseWithMalformedAppAppleId.json', + 'GET', + 'https://local-testing-base-url/inApps/v1/history/1234', + {'revision': ['revision_input'], + 'startDate': ['123455'], + 'endDate': ['123456'], + 'productId': ['com.example.1', 'com.example.2'], + 'productType': ['CONSUMABLE', 'AUTO_RENEWABLE'], + 'sort': ['ASCENDING'], + 'subscriptionGroupIdentifier': ['sub_group_id', 'sub_group_id_2'], + 'inAppOwnershipType': ['FAMILY_SHARED'], + 'revoked': ['False']}, + None) + + request = TransactionHistoryRequest( + sort=Order.ASCENDING, + productTypes=[ProductType.CONSUMABLE, ProductType.AUTO_RENEWABLE], + endDate=123456, + startDate=123455, + revoked=False, + inAppOwnershipType=InAppOwnershipType.FAMILY_SHARED, + productIds=['com.example.1', 'com.example.2'], + subscriptionGroupIdentifiers=['sub_group_id', 'sub_group_id_2'] + ) + + try: + client.get_transaction_history('1234', 'revision_input', request) + except Exception: + return + + self.assertFalse(True) + + def test_set_app_account_token(self): + client = self.get_client_with_body(b'', + 'PUT', + 'https://local-testing-base-url/inApps/v1/transactions/49571273/appAccountToken', + {}, + { + 'appAccountToken': '7389a31a-fb6d-4569-a2a6-db7d85d84813' + }) + update_app_account_token_request = UpdateAppAccountTokenRequest(appAccountToken='7389a31a-fb6d-4569-a2a6-db7d85d84813') + client.set_app_account_token('49571273', update_app_account_token_request) + + def test_invalid_app_account_token_error(self): + client = self.get_client_with_body_from_file('tests/resources/models/invalidAppAccountTokenUUIDError.json', + 'PUT', + 'https://local-testing-base-url/inApps/v1/transactions/49571273/appAccountToken', + {}, + None, + 400) + try: + client.set_app_account_token('49571273', None) + except APIException as e: + self.assertEqual(400, e.http_status_code) + self.assertEqual(4000183, e.raw_api_error) + self.assertEqual(APIError.INVALID_APP_ACCOUNT_TOKEN_UUID_ERROR, e.api_error) + self.assertEqual("Invalid request. The app account token field must be a valid UUID.", e.error_message) + return + + self.assertFalse(True) + + def test_family_transaction_not_supported_error(self): + client = self.get_client_with_body_from_file('tests/resources/models/familyTransactionNotSupportedError.json', + 'PUT', + 'https://local-testing-base-url/inApps/v1/transactions/1234' + '/appAccountToken', + {}, + None, + 400) + try: + client.set_app_account_token('1234', None) + except APIException as e: + self.assertEqual(400, e.http_status_code) + self.assertEqual(4000185, e.raw_api_error) + self.assertEqual(APIError.FAMILY_TRANSACTION_NOT_SUPPORTED_ERROR, e.api_error) + self.assertEqual("Invalid request. Family Sharing transactions aren't supported by this endpoint.", e.error_message) + return + + self.assertFalse(True) + + def test_transaction_id_not_original_transaction_id_error(self): + client = self.get_client_with_body_from_file( + 'tests/resources/models/transactionIdNotOriginalTransactionId.json', + 'PUT', + 'https://local-testing-base-url/inApps/v1/transactions/1234' + '/appAccountToken', + {}, + None, + 400) + try: + client.set_app_account_token('1234', None) + except APIException as e: + self.assertEqual(400, e.http_status_code) + self.assertEqual(4000187, e.raw_api_error) + self.assertEqual(APIError.TRANSACTION_ID_IS_NOT_ORIGINAL_TRANSACTION_ID_ERROR, e.api_error) + self.assertEqual("Invalid request. The transaction ID provided is not an original transaction ID.", e.error_message) + return + + self.assertFalse(True) + + + def test_upload_image(self): + client = self.get_client_with_body(b'', + 'PUT', + 'https://local-testing-base-url/inApps/v1/messaging/image/a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890', + {}, + None, + 200, + bytes([1, 2, 3]), + 'image/png') + client.upload_image(UUID('a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890'), bytes([1, 2, 3])) + + def test_delete_image(self): + client = self.get_client_with_body(b'', + 'DELETE', + 'https://local-testing-base-url/inApps/v1/messaging/image/a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890', + {}, + None) + client.delete_image(UUID('a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890')) + + def test_get_image_list(self): + client = self.get_client_with_body_from_file('tests/resources/models/getImageListResponse.json', + 'GET', + 'https://local-testing-base-url/inApps/v1/messaging/image/list', + {}, + None) + response = client.get_image_list() + self.assertIsNotNone(response) + self.assertEqual(1, len(response.imageIdentifiers)) + self.assertEqual(UUID('a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890'), response.imageIdentifiers[0].imageIdentifier) + self.assertEqual(ImageState.APPROVED, response.imageIdentifiers[0].imageState) + self.assertEqual(ImageSize.FULL_SIZE, response.imageIdentifiers[0].imageSize) + + def test_upload_message(self): + client = self.get_client_with_body(b'', + 'PUT', + 'https://local-testing-base-url/inApps/v1/messaging/message/a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890', + {}, + {'header': 'Header text', 'body': 'Body text'}) + upload_message_request_body = UploadMessageRequestBody(header='Header text', body='Body text') + client.upload_message(UUID('a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890'), upload_message_request_body) + + def test_upload_message_with_image(self): + client = self.get_client_with_body(b'', + 'PUT', + 'https://local-testing-base-url/inApps/v1/messaging/message/a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890', + {}, + {'header': 'Header text', 'body': 'Body text', 'image': {'imageIdentifier': 'b2c3d4e5-f6a7-8901-b2c3-d4e5f6a78901', 'altText': 'Alt text'}}) + image = UploadMessageImage(imageIdentifier=UUID('b2c3d4e5-f6a7-8901-b2c3-d4e5f6a78901'), altText='Alt text') + upload_message_request_body = UploadMessageRequestBody(header='Header text', body='Body text', image=image) + client.upload_message(UUID('a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890'), upload_message_request_body) + + def test_delete_message(self): + client = self.get_client_with_body(b'', + 'DELETE', + 'https://local-testing-base-url/inApps/v1/messaging/message/a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890', + {}, + None) + client.delete_message(UUID('a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890')) + + def test_get_message_list(self): + client = self.get_client_with_body_from_file('tests/resources/models/getMessageListResponse.json', + 'GET', + 'https://local-testing-base-url/inApps/v1/messaging/message/list', + {}, + None) + response = client.get_message_list() + self.assertIsNotNone(response) + self.assertEqual(1, len(response.messageIdentifiers)) + self.assertEqual(UUID('a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890'), response.messageIdentifiers[0].messageIdentifier) + self.assertEqual(MessageState.APPROVED, response.messageIdentifiers[0].messageState) + + def test_configure_default_message(self): + client = self.get_client_with_body(b'', + 'PUT', + 'https://local-testing-base-url/inApps/v1/messaging/default/com.example.product/en-US', + {}, + {'messageIdentifier': 'a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890'}) + default_configuration_request = DefaultConfigurationRequest(messageIdentifier=UUID('a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890')) + client.configure_default_message('com.example.product', 'en-US', default_configuration_request) + + def test_delete_default_message(self): + client = self.get_client_with_body(b'', + 'DELETE', + 'https://local-testing-base-url/inApps/v1/messaging/default/com.example.product/en-US', + {}, + None) + client.delete_default_message('com.example.product', 'en-US') + + def test_get_default_message(self): + client = self.get_client_with_body_from_file('tests/resources/models/getDefaultMessageResponse.json', + 'GET', + 'https://local-testing-base-url/inApps/v1/messaging/default/com.example.product/en-US', + {}, + None) + response = client.get_default_message('com.example.product', 'en-US') + self.assertIsNotNone(response) + self.assertEqual(UUID('a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890'), response.messageIdentifier) + + def test_upload_image_with_image_size(self): + client = self.get_client_with_body(b'', + 'PUT', + 'https://local-testing-base-url/inApps/v1/messaging/image/a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890', + {'imageSize': ['FULL_SIZE']}, + None, + 200, + bytes([1, 2, 3]), + 'image/png') + client.upload_image(UUID('a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890'), bytes([1, 2, 3]), ImageSize.FULL_SIZE) + + def test_configure_realtime_url(self): + client = self.get_client_with_body(b'', + 'PUT', + 'https://local-testing-base-url/inApps/v1/messaging/realtime/url', + {}, + {'realtimeURL': 'https://example.com/realtime'}) + realtime_url_request = RealtimeUrlRequest(realtimeURL='https://example.com/realtime') + client.configure_realtime_url(realtime_url_request) + + def test_delete_realtime_url(self): + client = self.get_client_with_body(b'', + 'DELETE', + 'https://local-testing-base-url/inApps/v1/messaging/realtime/url', + {}, + None) + client.delete_realtime_url() + + def test_get_realtime_url(self): + client = self.get_client_with_body_from_file('tests/resources/models/getRealtimeUrlResponse.json', + 'GET', + 'https://local-testing-base-url/inApps/v1/messaging/realtime/url', + {}, + None) + response = client.get_realtime_url() + self.assertIsNotNone(response) + self.assertEqual('https://example.com/realtime', response.realtimeURL) + + def test_upload_message_with_bullet_points(self): + client = self.get_client_with_body(b'', + 'PUT', + 'https://local-testing-base-url/inApps/v1/messaging/message/a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890', + {}, + {'header': 'Header text', 'body': 'Body text', + 'image': {'imageIdentifier': 'b2c3d4e5-f6a7-8901-b2c3-d4e5f6a78901', 'altText': 'Alt text'}, + 'headerPosition': 'ABOVE_IMAGE', + 'bulletPoints': [{'text': 'Bullet 1', 'imageIdentifier': 'c3d4e5f6-a7b8-9012-c3d4-e5f6a7b89012', 'altText': 'Bullet alt'}]}) + image = UploadMessageImage(imageIdentifier=UUID('b2c3d4e5-f6a7-8901-b2c3-d4e5f6a78901'), altText='Alt text') + bullet_point = BulletPoint(text='Bullet 1', imageIdentifier=UUID('c3d4e5f6-a7b8-9012-c3d4-e5f6a7b89012'), altText='Bullet alt') + upload_message_request_body = UploadMessageRequestBody(header='Header text', body='Body text', image=image, headerPosition=HeaderPosition.ABOVE_IMAGE, bulletPoints=[bullet_point]) + client.upload_message(UUID('a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890'), upload_message_request_body) + + def test_initiate_performance_test(self): + client = self.get_client_with_body_from_file('tests/resources/models/performanceTestResponse.json', + 'POST', + 'https://local-testing-base-url/inApps/v1/messaging/performanceTest', + {}, + {'originalTransactionId': '70000500092808'}) + performance_test_request = PerformanceTestRequest(originalTransactionId='70000500092808') + response = client.initiate_performance_test(performance_test_request) + self.assertIsNotNone(response) + self.assertEqual('c4b87a1d-2e3f-4a5b-9c6d-7e8f9a0b1c2d', response.requestId) + self.assertIsNotNone(response.config) + self.assertEqual(10, response.config.maxConcurrentRequests) + self.assertEqual(100, response.config.totalRequests) + self.assertEqual(60000, response.config.totalDuration) + self.assertEqual(500, response.config.responseTimeThreshold) + self.assertEqual(95, response.config.successRateThreshold) + + def test_get_performance_test_results(self): + client = self.get_client_with_body_from_file('tests/resources/models/performanceTestResultResponse.json', + 'GET', + 'https://local-testing-base-url/inApps/v1/messaging/performanceTest/result/c4b87a1d-2e3f-4a5b-9c6d-7e8f9a0b1c2d', + {}, + None) + response = client.get_performance_test_results('c4b87a1d-2e3f-4a5b-9c6d-7e8f9a0b1c2d') + self.assertIsNotNone(response) + self.assertIsNotNone(response.config) + self.assertEqual(10, response.config.maxConcurrentRequests) + self.assertEqual(100, response.config.totalRequests) + self.assertEqual(60000, response.config.totalDuration) + self.assertEqual(500, response.config.responseTimeThreshold) + self.assertEqual(95, response.config.successRateThreshold) + self.assertEqual('https://example.com/retention', response.target) + self.assertEqual(PerformanceTestStatus.PASS, response.result) + self.assertEqual('PASS', response.rawResult) + self.assertEqual(98, response.successRate) + self.assertEqual(0, response.numPending) + self.assertIsNotNone(response.responseTimes) + self.assertEqual(120, response.responseTimes.average) + self.assertEqual(100, response.responseTimes.p50) + self.assertEqual(200, response.responseTimes.p90) + self.assertEqual(250, response.responseTimes.p95) + self.assertEqual(400, response.responseTimes.p99) + self.assertEqual({SendAttemptResult.TIMED_OUT: 1, SendAttemptResult.NO_RESPONSE: 1}, response.failures) + self.assertEqual({'TIMED_OUT': 1, 'NO_RESPONSE': 1}, response.rawFailures) + + def test_get_app_transaction_info_success(self): + client = self.get_client_with_body_from_file('tests/resources/models/appTransactionInfoResponse.json', + 'GET', + 'https://local-testing-base-url/inApps/v1/transactions/appTransactions/1234', + {}, + None) + + app_transaction_info_response = client.get_app_transaction_info('1234') + + self.assertIsNotNone(app_transaction_info_response) + self.assertEqual('signed_app_transaction_info_value', app_transaction_info_response.signedAppTransactionInfo) + + def test_get_app_transaction_info_invalid_transaction_id(self): + client = self.get_client_with_body_from_file('tests/resources/models/invalidTransactionIdError.json', + 'GET', + 'https://local-testing-base-url/inApps/v1/transactions/appTransactions/invalid_id', + {}, + None, + 400) + try: + client.get_app_transaction_info('invalid_id') + except APIException as e: + self.assertEqual(400, e.http_status_code) + self.assertEqual(4000006, e.raw_api_error) + self.assertEqual(APIError.INVALID_TRANSACTION_ID, e.api_error) + self.assertEqual("Invalid transaction id.", e.error_message) + return + self.assertFalse(True) + + def test_get_app_transaction_info_app_transaction_does_not_exist(self): + client = self.get_client_with_body_from_file('tests/resources/models/appTransactionDoesNotExistError.json', + 'GET', + 'https://local-testing-base-url/inApps/v1/transactions/appTransactions/nonexistent_id', + {}, + None, + 404) + try: + client.get_app_transaction_info('nonexistent_id') + except APIException as e: + self.assertEqual(404, e.http_status_code) + self.assertEqual(4040019, e.raw_api_error) + self.assertEqual(APIError.APP_TRANSACTION_DOES_NOT_EXIST_ERROR, e.api_error) + self.assertEqual("No AppTransaction exists for the customer.", e.error_message) + return + + self.assertFalse(True) + + def test_get_app_transaction_info_transaction_id_not_found(self): + client = self.get_client_with_body_from_file('tests/resources/models/transactionIdNotFoundError.json', + 'GET', + 'https://local-testing-base-url/inApps/v1/transactions/appTransactions/not_found_id', + {}, + None, + 404) + try: + client.get_app_transaction_info('not_found_id') + except APIException as e: + self.assertEqual(404, e.http_status_code) + self.assertEqual(4040010, e.raw_api_error) + self.assertEqual(APIError.TRANSACTION_ID_NOT_FOUND, e.api_error) + self.assertEqual("Transaction id not found.", e.error_message) + return + + self.assertFalse(True) + + def test_finish_transaction(self): + client = self.get_client_with_body(b'', + 'POST', + 'https://local-testing-base-url/inApps/v1/transactions/1234/finish', + {}, + None) + client.finish_transaction('1234') + + + def get_signing_key(self): + return read_data_from_binary_file('tests/resources/certs/testSigningKey.p8') + + def get_client_with_body(self, body: str, expected_method: str, expected_url: str, expected_params: Dict[str, Union[str, List[str]]], expected_json: Dict[str, Any], status_code: int = 200, expected_data: bytes = None, expected_content_type: str = None): + signing_key = self.get_signing_key() client = AppStoreServerAPIClient(signing_key, 'keyId', 'issuerId', 'com.example', Environment.LOCAL_TESTING) - def fake_execute_and_validate_inputs(method: bytes, url: str, params: Dict[str, Union[str, List[str]]], headers: Dict[str, str], json: Dict[str, Any]): + def fake_execute_and_validate_inputs(method: bytes, url: str, params: Dict[str, Union[str, List[str]]], headers: Dict[str, str], json: Dict[str, Any], data: bytes): self.assertEqual(expected_method, method) self.assertEqual(expected_url, url) self.assertEqual(expected_params, params) - self.assertEqual(['User-Agent', 'Authorization', 'Accept'], list(headers.keys())) - self.assertEqual('application/json', headers['Accept']) self.assertTrue(headers['User-Agent'].startswith('app-store-server-library/python')) self.assertTrue(headers['Authorization'].startswith('Bearer ')) + self.assertEqual('application/json', headers['Accept']) decoded_jwt = decode_json_from_signed_date(headers['Authorization'][7:]) self.assertEqual('appstoreconnect-v1', decoded_jwt['payload']['aud']) self.assertEqual('issuerId', decoded_jwt['payload']['iss']) self.assertEqual('keyId', decoded_jwt['header']['kid']) self.assertEqual('com.example', decoded_jwt['payload']['bid']) - self.assertEqual(expected_json, json) + + # Content-specific validation + if expected_data is not None: + self.assertEqual(['User-Agent', 'Authorization', 'Accept', 'Content-Type'], list(headers.keys())) + self.assertEqual(expected_content_type, headers['Content-Type']) + self.assertIsNone(json) + self.assertEqual(expected_data, data) + else: + self.assertEqual(['User-Agent', 'Authorization', 'Accept'], list(headers.keys())) + self.assertEqual(expected_json, json) + response = Response() response.status_code = status_code response.raw = BytesIO(body) diff --git a/tests/test_api_client_async.py b/tests/test_api_client_async.py new file mode 100644 index 00000000..8b837261 --- /dev/null +++ b/tests/test_api_client_async.py @@ -0,0 +1,911 @@ +# Copyright (c) 2023 Apple Inc. Licensed under MIT License. + +from typing import Any, Dict, List, Union +import unittest + +from httpx import Response + +from appstoreserverlibrary.api_client import APIError, APIException, AsyncAppStoreServerAPIClient, GetTransactionHistoryVersion +from appstoreserverlibrary.models.AccountTenure import AccountTenure +from appstoreserverlibrary.models.AppTransactionInfoResponse import AppTransactionInfoResponse +from appstoreserverlibrary.models.AutoRenewStatus import AutoRenewStatus +from appstoreserverlibrary.models.ConsumptionRequest import ConsumptionRequest +from appstoreserverlibrary.models.ConsumptionRequestV1 import ConsumptionRequestV1 +from appstoreserverlibrary.models.ConsumptionStatus import ConsumptionStatus +from appstoreserverlibrary.models.DeliveryStatus import DeliveryStatus +from appstoreserverlibrary.models.DeliveryStatusV1 import DeliveryStatusV1 +from appstoreserverlibrary.models.Environment import Environment +from appstoreserverlibrary.models.ExpirationIntent import ExpirationIntent +from appstoreserverlibrary.models.ExtendReasonCode import ExtendReasonCode +from appstoreserverlibrary.models.ExtendRenewalDateRequest import ExtendRenewalDateRequest +from appstoreserverlibrary.models.InAppOwnershipType import InAppOwnershipType +from appstoreserverlibrary.models.LastTransactionsItem import LastTransactionsItem +from appstoreserverlibrary.models.LifetimeDollarsPurchased import LifetimeDollarsPurchased +from appstoreserverlibrary.models.LifetimeDollarsRefunded import LifetimeDollarsRefunded +from appstoreserverlibrary.models.MassExtendRenewalDateRequest import MassExtendRenewalDateRequest +from appstoreserverlibrary.models.NotificationHistoryRequest import NotificationHistoryRequest +from appstoreserverlibrary.models.NotificationHistoryResponseItem import NotificationHistoryResponseItem +from appstoreserverlibrary.models.NotificationTypeV2 import NotificationTypeV2 +from appstoreserverlibrary.models.OfferType import OfferType +from appstoreserverlibrary.models.OrderLookupStatus import OrderLookupStatus +from appstoreserverlibrary.models.Platform import Platform +from appstoreserverlibrary.models.PlayTime import PlayTime +from appstoreserverlibrary.models.PriceIncreaseStatus import PriceIncreaseStatus +from appstoreserverlibrary.models.RefundPreference import RefundPreference +from appstoreserverlibrary.models.RefundPreferenceV1 import RefundPreferenceV1 +from appstoreserverlibrary.models.RevocationReason import RevocationReason +from appstoreserverlibrary.models.SendAttemptItem import SendAttemptItem +from appstoreserverlibrary.models.SendAttemptResult import SendAttemptResult +from appstoreserverlibrary.models.Status import Status +from appstoreserverlibrary.models.SubscriptionGroupIdentifierItem import SubscriptionGroupIdentifierItem +from appstoreserverlibrary.models.Subtype import Subtype +from appstoreserverlibrary.models.TransactionHistoryRequest import Order, ProductType, TransactionHistoryRequest +from appstoreserverlibrary.models.TransactionReason import TransactionReason +from appstoreserverlibrary.models.Type import Type +from appstoreserverlibrary.models.UpdateAppAccountTokenRequest import UpdateAppAccountTokenRequest +from appstoreserverlibrary.models.UserStatus import UserStatus +from appstoreserverlibrary.models.DefaultConfigurationRequest import DefaultConfigurationRequest +from appstoreserverlibrary.models.HeaderPosition import HeaderPosition +from appstoreserverlibrary.models.ImageSize import ImageSize +from appstoreserverlibrary.models.ImageState import ImageState +from appstoreserverlibrary.models.MessageState import MessageState +from appstoreserverlibrary.models.BulletPoint import BulletPoint +from appstoreserverlibrary.models.PerformanceTestRequest import PerformanceTestRequest +from appstoreserverlibrary.models.PerformanceTestStatus import PerformanceTestStatus +from appstoreserverlibrary.models.RealtimeUrlRequest import RealtimeUrlRequest +from appstoreserverlibrary.models.UploadMessageImage import UploadMessageImage +from appstoreserverlibrary.models.UploadMessageRequestBody import UploadMessageRequestBody +from uuid import UUID + +from tests.util import decode_json_from_signed_date, read_data_from_binary_file, read_data_from_file + +from io import BytesIO + +class DecodedPayloads(unittest.IsolatedAsyncioTestCase): + async def test_extend_renewal_date_for_all_active_subscribers(self): + client = self.get_client_with_body_from_file('tests/resources/models/extendRenewalDateForAllActiveSubscribersResponse.json', + 'POST', + 'https://local-testing-base-url/inApps/v1/subscriptions/extend/mass', + {}, + {'extendByDays': 45, 'extendReasonCode': 1, 'requestIdentifier': 'fdf964a4-233b-486c-aac1-97d8d52688ac', 'storefrontCountryCodes': ['USA', 'MEX'], 'productId': 'com.example.productId'}) + + extend_renewal_date_request = MassExtendRenewalDateRequest( + extendByDays=45, + extendReasonCode=ExtendReasonCode.CUSTOMER_SATISFACTION, + requestIdentifier='fdf964a4-233b-486c-aac1-97d8d52688ac', + storefrontCountryCodes=['USA', 'MEX'], + productId='com.example.productId') + + mass_extend_renewal_date_response = await client.extend_renewal_date_for_all_active_subscribers(extend_renewal_date_request) + + self.assertIsNotNone(mass_extend_renewal_date_response) + self.assertEqual('758883e8-151b-47b7-abd0-60c4d804c2f5', mass_extend_renewal_date_response.requestIdentifier) + + async def test_extend_subscription_renewal_date(self): + client = self.get_client_with_body_from_file('tests/resources/models/extendSubscriptionRenewalDateResponse.json', + 'PUT', + 'https://local-testing-base-url/inApps/v1/subscriptions/extend/4124214', + {}, + {'extendByDays': 45, 'extendReasonCode': 1, 'requestIdentifier': 'fdf964a4-233b-486c-aac1-97d8d52688ac'}) + + extend_renewal_date_request = ExtendRenewalDateRequest( + extendByDays=45, + extendReasonCode=ExtendReasonCode.CUSTOMER_SATISFACTION, + requestIdentifier='fdf964a4-233b-486c-aac1-97d8d52688ac' + ) + + extend_renewal_date_response = await client.extend_subscription_renewal_date('4124214', extend_renewal_date_request) + + self.assertIsNotNone(extend_renewal_date_response) + self.assertEqual('2312412', extend_renewal_date_response.originalTransactionId) + self.assertEqual('9993', extend_renewal_date_response.webOrderLineItemId) + self.assertTrue(extend_renewal_date_response.success) + self.assertEqual(1698148900000, extend_renewal_date_response.effectiveDate) + + async def test_get_all_subscription_statuses(self): + client = self.get_client_with_body_from_file('tests/resources/models/getAllSubscriptionStatusesResponse.json', + 'GET', + 'https://local-testing-base-url/inApps/v1/subscriptions/4321', + {'status': [2, 1]}, + None) + + status_response = await client.get_all_subscription_statuses('4321', [Status.EXPIRED, Status.ACTIVE]) + + self.assertIsNotNone(status_response) + self.assertEqual(Environment.LOCAL_TESTING, status_response.environment) + self.assertEqual('LocalTesting', status_response.rawEnvironment) + self.assertEqual('com.example', status_response.bundleId) + self.assertEqual(5454545, status_response.appAppleId) + + + expected_body = [ + SubscriptionGroupIdentifierItem( + subscriptionGroupIdentifier='sub_group_one', + lastTransactions=[ + LastTransactionsItem( + status=Status.ACTIVE, + originalTransactionId='3749183', + signedTransactionInfo='signed_transaction_one', + signedRenewalInfo='signed_renewal_one' + ), + LastTransactionsItem( + status=Status.REVOKED, + originalTransactionId='5314314134', + signedTransactionInfo='signed_transaction_two', + signedRenewalInfo='signed_renewal_two' + ) + ] + ), + SubscriptionGroupIdentifierItem( + subscriptionGroupIdentifier='sub_group_two', + lastTransactions=[ + LastTransactionsItem( + status=Status.EXPIRED, + originalTransactionId='3413453', + signedTransactionInfo='signed_transaction_three', + signedRenewalInfo='signed_renewal_three' + ) + ] + ) + ] + self.assertEqual(expected_body, status_response.data) + + async def test_get_refund_history(self): + client = self.get_client_with_body_from_file('tests/resources/models/getRefundHistoryResponse.json', + 'GET', + 'https://local-testing-base-url/inApps/v2/refund/lookup/555555', + {'revision': ['revision_input']}, + None) + + refund_history_response = await client.get_refund_history('555555', 'revision_input') + + self.assertIsNotNone(refund_history_response) + self.assertEqual(['signed_transaction_one', 'signed_transaction_two'], refund_history_response.signedTransactions) + self.assertEqual('revision_output', refund_history_response.revision) + self.assertTrue(refund_history_response.hasMore) + + async def test_get_status_of_subscription_renewal_date_extensions(self): + client = self.get_client_with_body_from_file('tests/resources/models/getStatusOfSubscriptionRenewalDateExtensionsResponse.json', + 'GET', + 'https://local-testing-base-url/inApps/v1/subscriptions/extend/mass/20fba8a0-2b80-4a7d-a17f-85c1854727f8/com.example.product', + {}, + None) + + mass_extend_renewal_date_status_response = await client.get_status_of_subscription_renewal_date_extensions('com.example.product', '20fba8a0-2b80-4a7d-a17f-85c1854727f8') + + self.assertIsNotNone(mass_extend_renewal_date_status_response) + self.assertEqual('20fba8a0-2b80-4a7d-a17f-85c1854727f8', mass_extend_renewal_date_status_response.requestIdentifier) + self.assertTrue(mass_extend_renewal_date_status_response.complete) + self.assertEqual(1698148900000, mass_extend_renewal_date_status_response.completeDate) + self.assertEqual(30, mass_extend_renewal_date_status_response.succeededCount) + self.assertEqual(2, mass_extend_renewal_date_status_response.failedCount) + + async def test_get_test_notification_status(self): + client = self.get_client_with_body_from_file('tests/resources/models/getTestNotificationStatusResponse.json', + 'GET', + 'https://local-testing-base-url/inApps/v1/notifications/test/8cd2974c-f905-492a-bf9a-b2f47c791d19', + {}, + None) + + check_test_notification_response = await client.get_test_notification_status('8cd2974c-f905-492a-bf9a-b2f47c791d19') + + self.assertIsNotNone(check_test_notification_response) + self.assertEqual('signed_payload', check_test_notification_response.signedPayload) + sendAttemptItems = [ + SendAttemptItem(attemptDate=1698148900000,sendAttemptResult=SendAttemptResult.NO_RESPONSE), + SendAttemptItem(attemptDate=1698148950000,sendAttemptResult=SendAttemptResult.SUCCESS) + ] + self.assertEqual(sendAttemptItems, check_test_notification_response.sendAttempts) + + async def test_get_notification_history(self): + client = self.get_client_with_body_from_file('tests/resources/models/getNotificationHistoryResponse.json', + 'POST', + 'https://local-testing-base-url/inApps/v1/notifications/history', + {'paginationToken': ['a036bc0e-52b8-4bee-82fc-8c24cb6715d6']}, + {'startDate': 1698148900000, 'endDate': 1698148950000, 'notificationType': 'SUBSCRIBED', 'notificationSubtype': 'INITIAL_BUY', 'transactionId': '999733843', 'onlyFailures': True}) + + notification_history_request = NotificationHistoryRequest( + startDate=1698148900000, + endDate=1698148950000, + notificationType=NotificationTypeV2.SUBSCRIBED, + notificationSubtype=Subtype.INITIAL_BUY, + transactionId='999733843', + onlyFailures=True + ) + + notification_history_response = await client.get_notification_history('a036bc0e-52b8-4bee-82fc-8c24cb6715d6', notification_history_request) + + self.assertIsNotNone(notification_history_response) + self.assertEqual('57715481-805a-4283-8499-1c19b5d6b20a', notification_history_response.paginationToken) + self.assertTrue(notification_history_response.hasMore) + expected_notification_history = [ + NotificationHistoryResponseItem(sendAttempts=[ + SendAttemptItem( + attemptDate=1698148900000, + sendAttemptResult=SendAttemptResult.NO_RESPONSE + ), + SendAttemptItem( + attemptDate=1698148950000, + rawSendAttemptResult='SUCCESS' + ) + ], signedPayload='signed_payload_one'), + NotificationHistoryResponseItem(sendAttempts=[ + SendAttemptItem( + attemptDate=1698148800000, + sendAttemptResult=SendAttemptResult.CIRCULAR_REDIRECT + ) + ], signedPayload='signed_payload_two') + ] + self.assertEqual(expected_notification_history, notification_history_response.notificationHistory) + + async def test_get_transaction_history_v1(self): + client = self.get_client_with_body_from_file('tests/resources/models/transactionHistoryResponse.json', + 'GET', + 'https://local-testing-base-url/inApps/v1/history/1234', + {'revision': ['revision_input'], + 'startDate': ['123455'], + 'endDate': ['123456'], + 'productId': ['com.example.1', 'com.example.2'], + 'productType': ['CONSUMABLE', 'AUTO_RENEWABLE'], + 'sort': ['ASCENDING'], + 'subscriptionGroupIdentifier': ['sub_group_id', 'sub_group_id_2'], + 'inAppOwnershipType': ['FAMILY_SHARED'], + 'revoked': ['False']}, + None) + + request = TransactionHistoryRequest( + sort=Order.ASCENDING, + productTypes=[ProductType.CONSUMABLE, ProductType.AUTO_RENEWABLE], + endDate=123456, + startDate=123455, + revoked=False, + inAppOwnershipType=InAppOwnershipType.FAMILY_SHARED, + productIds=['com.example.1', 'com.example.2'], + subscriptionGroupIdentifiers=['sub_group_id', 'sub_group_id_2'] + ) + + history_response = await client.get_transaction_history('1234', 'revision_input', request, GetTransactionHistoryVersion.V1) + + self.assertIsNotNone(history_response) + self.assertEqual('revision_output', history_response.revision) + self.assertTrue(history_response.hasMore) + self.assertEqual('com.example', history_response.bundleId) + self.assertEqual(323232, history_response.appAppleId) + self.assertEqual(Environment.LOCAL_TESTING, history_response.environment) + self.assertEqual('LocalTesting', history_response.rawEnvironment) + self.assertEqual(['signed_transaction_value', 'signed_transaction_value2'], history_response.signedTransactions) + + async def test_get_transaction_history_v2(self): + client = self.get_client_with_body_from_file('tests/resources/models/transactionHistoryResponse.json', + 'GET', + 'https://local-testing-base-url/inApps/v2/history/1234', + {'revision': ['revision_input'], + 'startDate': ['123455'], + 'endDate': ['123456'], + 'productId': ['com.example.1', 'com.example.2'], + 'productType': ['CONSUMABLE', 'AUTO_RENEWABLE'], + 'sort': ['ASCENDING'], + 'subscriptionGroupIdentifier': ['sub_group_id', 'sub_group_id_2'], + 'inAppOwnershipType': ['FAMILY_SHARED'], + 'revoked': ['False']}, + None) + + request = TransactionHistoryRequest( + sort=Order.ASCENDING, + productTypes=[ProductType.CONSUMABLE, ProductType.AUTO_RENEWABLE], + endDate=123456, + startDate=123455, + revoked=False, + inAppOwnershipType=InAppOwnershipType.FAMILY_SHARED, + productIds=['com.example.1', 'com.example.2'], + subscriptionGroupIdentifiers=['sub_group_id', 'sub_group_id_2'] + ) + + history_response = await client.get_transaction_history('1234', 'revision_input', request, GetTransactionHistoryVersion.V2) + + self.assertIsNotNone(history_response) + self.assertEqual('revision_output', history_response.revision) + self.assertTrue(history_response.hasMore) + self.assertEqual('com.example', history_response.bundleId) + self.assertEqual(323232, history_response.appAppleId) + self.assertEqual(Environment.LOCAL_TESTING, history_response.environment) + self.assertEqual('LocalTesting', history_response.rawEnvironment) + self.assertEqual(['signed_transaction_value', 'signed_transaction_value2'], history_response.signedTransactions) + + async def test_get_transaction_info(self): + client = self.get_client_with_body_from_file('tests/resources/models/transactionInfoResponse.json', + 'GET', + 'https://local-testing-base-url/inApps/v1/transactions/1234', + {}, + None) + + transaction_info_response = await client.get_transaction_info('1234') + + self.assertIsNotNone(transaction_info_response) + self.assertEqual('signed_transaction_info_value', transaction_info_response.signedTransactionInfo) + + async def test_look_up_order_id(self): + client = self.get_client_with_body_from_file('tests/resources/models/lookupOrderIdResponse.json', + 'GET', + 'https://local-testing-base-url/inApps/v1/lookup/W002182', + {}, + None) + + order_lookup_response = await client.look_up_order_id('W002182') + + self.assertIsNotNone(order_lookup_response) + self.assertEqual(OrderLookupStatus.INVALID, order_lookup_response.status) + self.assertEqual(1, order_lookup_response.rawStatus) + self.assertEqual(['signed_transaction_one', 'signed_transaction_two'], order_lookup_response.signedTransactions) + + async def test_request_test_notification(self): + client = self.get_client_with_body_from_file('tests/resources/models/requestTestNotificationResponse.json', + 'POST', + 'https://local-testing-base-url/inApps/v1/notifications/test', + {}, + None) + + send_test_notification_response = await client.request_test_notification() + + self.assertIsNotNone(send_test_notification_response) + self.assertEqual('ce3af791-365e-4c60-841b-1674b43c1609', send_test_notification_response.testNotificationToken) + + async def test_send_consumption_data(self): + client = self.get_client_with_body(b'', + 'PUT', + 'https://local-testing-base-url/inApps/v1/transactions/consumption/49571273', + {}, + {'customerConsented': True, + 'consumptionStatus': 1, + 'platform': 2, + 'sampleContentProvided': False, + 'deliveryStatus': 3, + 'appAccountToken': '7389a31a-fb6d-4569-a2a6-db7d85d84813', + 'accountTenure': 4, + 'playTime': 5, + 'lifetimeDollarsRefunded': 6, + 'lifetimeDollarsPurchased': 7, + 'userStatus': 4, + 'refundPreference': 3}) + + consumptionRequest = ConsumptionRequestV1( + customerConsented=True, + consumptionStatus=ConsumptionStatus.NOT_CONSUMED, + platform=Platform.NON_APPLE, + sampleContentProvided=False, + deliveryStatus=DeliveryStatusV1.DID_NOT_DELIVER_DUE_TO_SERVER_OUTAGE, + appAccountToken='7389a31a-fb6d-4569-a2a6-db7d85d84813', + accountTenure=AccountTenure.THIRTY_DAYS_TO_NINETY_DAYS, + playTime=PlayTime.ONE_DAY_TO_FOUR_DAYS, + lifetimeDollarsRefunded=LifetimeDollarsRefunded.ONE_THOUSAND_DOLLARS_TO_ONE_THOUSAND_NINE_HUNDRED_NINETY_NINE_DOLLARS_AND_NINETY_NINE_CENTS, + lifetimeDollarsPurchased=LifetimeDollarsPurchased.TWO_THOUSAND_DOLLARS_OR_GREATER, + userStatus=UserStatus.LIMITED_ACCESS, + refundPreference=RefundPreferenceV1.NO_PREFERENCE + ) + + await client.send_consumption_data('49571273', consumptionRequest) + + async def test_send_consumption_information(self): + client = self.get_client_with_body(b'', + 'PUT', + 'https://local-testing-base-url/inApps/v2/transactions/consumption/49571273', + {}, + {'customerConsented': True, + 'sampleContentProvided': False, + 'deliveryStatus': 'DELIVERED', + 'consumptionPercentage': 50000, + 'refundPreference': 'GRANT_FULL'}) + + consumptionRequest = ConsumptionRequest( + customerConsented=True, + sampleContentProvided=False, + deliveryStatus=DeliveryStatus.DELIVERED, + consumptionPercentage=50000, + refundPreference=RefundPreference.GRANT_FULL + ) + + await client.send_consumption_information('49571273', consumptionRequest) + + async def test_api_error(self): + client = self.get_client_with_body_from_file('tests/resources/models/apiException.json', + 'POST', + 'https://local-testing-base-url/inApps/v1/notifications/test', + {}, + None, + 500) + try: + await client.request_test_notification() + except APIException as e: + self.assertEqual(500, e.http_status_code) + self.assertEqual(5000000, e.raw_api_error) + self.assertEqual(APIError.GENERAL_INTERNAL, e.api_error) + self.assertEqual("An unknown error occurred.", e.error_message) + return + + self.assertFalse(True) + + async def test_xcode_not_supported_error(self): + try: + signing_key = self.get_signing_key() + AsyncAppStoreServerAPIClient(signing_key, 'keyId', 'issuerId', 'com.example', Environment.XCODE) + except ValueError as e: + self.assertEqual("Xcode is not a supported environment for an AppStoreServerAPIClient", e.args[0]) + return + + self.assertFalse(True) + + async def test_api_too_many_requests(self): + client = self.get_client_with_body_from_file('tests/resources/models/apiTooManyRequestsException.json', + 'POST', + 'https://local-testing-base-url/inApps/v1/notifications/test', + {}, + None, + 429) + try: + await client.request_test_notification() + except APIException as e: + self.assertEqual(429, e.http_status_code) + self.assertEqual(4290000, e.raw_api_error) + self.assertEqual(APIError.RATE_LIMIT_EXCEEDED, e.api_error) + self.assertEqual("Rate limit exceeded.", e.error_message) + return + + self.assertFalse(True) + + async def test_unknown_error(self): + client = self.get_client_with_body_from_file('tests/resources/models/apiUnknownError.json', + 'POST', + 'https://local-testing-base-url/inApps/v1/notifications/test', + {}, + None, + 400) + try: + await client.request_test_notification() + except APIException as e: + self.assertEqual(400, e.http_status_code) + self.assertEqual(9990000, e.raw_api_error) + self.assertIsNone(e.api_error) + self.assertEqual("Testing error.", e.error_message) + return + + self.assertFalse(True) + + async def test_get_transaction_history_with_unknown_environment(self): + client = self.get_client_with_body_from_file('tests/resources/models/transactionHistoryResponseWithMalformedEnvironment.json', + 'GET', + 'https://local-testing-base-url/inApps/v2/history/1234', + {'revision': ['revision_input'], + 'startDate': ['123455'], + 'endDate': ['123456'], + 'productId': ['com.example.1', 'com.example.2'], + 'productType': ['CONSUMABLE', 'AUTO_RENEWABLE'], + 'sort': ['ASCENDING'], + 'subscriptionGroupIdentifier': ['sub_group_id', 'sub_group_id_2'], + 'inAppOwnershipType': ['FAMILY_SHARED'], + 'revoked': ['False']}, + None) + + request = TransactionHistoryRequest( + sort=Order.ASCENDING, + productTypes=[ProductType.CONSUMABLE, ProductType.AUTO_RENEWABLE], + endDate=123456, + startDate=123455, + revoked=False, + inAppOwnershipType=InAppOwnershipType.FAMILY_SHARED, + productIds=['com.example.1', 'com.example.2'], + subscriptionGroupIdentifiers=['sub_group_id', 'sub_group_id_2'] + ) + + history_response = await client.get_transaction_history('1234', 'revision_input', request, GetTransactionHistoryVersion.V2) + + self.assertIsNone(history_response.environment) + self.assertEqual("LocalTestingxxx", history_response.rawEnvironment) + + async def test_get_transaction_history_with_malformed_app_apple_id(self): + client = self.get_client_with_body_from_file('tests/resources/models/transactionHistoryResponseWithMalformedAppAppleId.json', + 'GET', + 'https://local-testing-base-url/inApps/v1/history/1234', + {'revision': ['revision_input'], + 'startDate': ['123455'], + 'endDate': ['123456'], + 'productId': ['com.example.1', 'com.example.2'], + 'productType': ['CONSUMABLE', 'AUTO_RENEWABLE'], + 'sort': ['ASCENDING'], + 'subscriptionGroupIdentifier': ['sub_group_id', 'sub_group_id_2'], + 'inAppOwnershipType': ['FAMILY_SHARED'], + 'revoked': ['False']}, + None) + + request = TransactionHistoryRequest( + sort=Order.ASCENDING, + productTypes=[ProductType.CONSUMABLE, ProductType.AUTO_RENEWABLE], + endDate=123456, + startDate=123455, + revoked=False, + inAppOwnershipType=InAppOwnershipType.FAMILY_SHARED, + productIds=['com.example.1', 'com.example.2'], + subscriptionGroupIdentifiers=['sub_group_id', 'sub_group_id_2'] + ) + + try: + await client.get_transaction_history('1234', 'revision_input', request) + except Exception: + return + + self.assertFalse(True) + + async def test_set_app_account_token(self): + client = self.get_client_with_body(b'', + 'PUT', + 'https://local-testing-base-url/inApps/v1/transactions/49571273/appAccountToken', + {}, + { + 'appAccountToken': '7389a31a-fb6d-4569-a2a6-db7d85d84813' + }) + update_app_account_token_request = UpdateAppAccountTokenRequest(appAccountToken='7389a31a-fb6d-4569-a2a6-db7d85d84813') + await client.set_app_account_token('49571273', update_app_account_token_request) + + async def test_invalid_app_account_token_error(self): + client = self.get_client_with_body_from_file('tests/resources/models/invalidAppAccountTokenUUIDError.json', + 'PUT', + 'https://local-testing-base-url/inApps/v1/transactions/49571273/appAccountToken', + {}, + None, + 400) + try: + await client.set_app_account_token('49571273', None) + except APIException as e: + self.assertEqual(400, e.http_status_code) + self.assertEqual(4000183, e.raw_api_error) + self.assertEqual(APIError.INVALID_APP_ACCOUNT_TOKEN_UUID_ERROR, e.api_error) + self.assertEqual("Invalid request. The app account token field must be a valid UUID.", e.error_message) + return + + self.assertFalse(True) + + async def test_family_transaction_not_supported_error(self): + client = self.get_client_with_body_from_file('tests/resources/models/familyTransactionNotSupportedError.json', + 'PUT', + 'https://local-testing-base-url/inApps/v1/transactions/1234' + '/appAccountToken', + {}, + None, + 400) + try: + await client.set_app_account_token('1234', None) + except APIException as e: + self.assertEqual(400, e.http_status_code) + self.assertEqual(4000185, e.raw_api_error) + self.assertEqual(APIError.FAMILY_TRANSACTION_NOT_SUPPORTED_ERROR, e.api_error) + self.assertEqual("Invalid request. Family Sharing transactions aren't supported by this endpoint.", e.error_message) + return + + self.assertFalse(True) + + async def test_transaction_id_not_original_transaction_id_error(self): + client = self.get_client_with_body_from_file( + 'tests/resources/models/transactionIdNotOriginalTransactionId.json', + 'PUT', + 'https://local-testing-base-url/inApps/v1/transactions/1234' + '/appAccountToken', + {}, + None, + 400) + try: + await client.set_app_account_token('1234', None) + except APIException as e: + self.assertEqual(400, e.http_status_code) + self.assertEqual(4000187, e.raw_api_error) + self.assertEqual(APIError.TRANSACTION_ID_IS_NOT_ORIGINAL_TRANSACTION_ID_ERROR, e.api_error) + self.assertEqual("Invalid request. The transaction ID provided is not an original transaction ID.", e.error_message) + return + + self.assertFalse(True) + + async def test_upload_image(self): + client = self.get_client_with_body(b'', + 'PUT', + 'https://local-testing-base-url/inApps/v1/messaging/image/a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890', + {}, + None, + 200, + bytes([1, 2, 3]), + 'image/png') + await client.upload_image(UUID('a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890'), bytes([1, 2, 3])) + + async def test_delete_image(self): + client = self.get_client_with_body(b'', + 'DELETE', + 'https://local-testing-base-url/inApps/v1/messaging/image/a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890', + {}, + None) + await client.delete_image(UUID('a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890')) + + async def test_get_image_list(self): + client = self.get_client_with_body_from_file('tests/resources/models/getImageListResponse.json', + 'GET', + 'https://local-testing-base-url/inApps/v1/messaging/image/list', + {}, + None) + response = await client.get_image_list() + self.assertIsNotNone(response) + self.assertEqual(1, len(response.imageIdentifiers)) + self.assertEqual(UUID('a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890'), response.imageIdentifiers[0].imageIdentifier) + self.assertEqual(ImageState.APPROVED, response.imageIdentifiers[0].imageState) + self.assertEqual(ImageSize.FULL_SIZE, response.imageIdentifiers[0].imageSize) + + async def test_upload_message(self): + client = self.get_client_with_body(b'', + 'PUT', + 'https://local-testing-base-url/inApps/v1/messaging/message/a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890', + {}, + {'header': 'Header text', 'body': 'Body text'}) + upload_message_request_body = UploadMessageRequestBody(header='Header text', body='Body text') + await client.upload_message(UUID('a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890'), upload_message_request_body) + + async def test_upload_message_with_image(self): + client = self.get_client_with_body(b'', + 'PUT', + 'https://local-testing-base-url/inApps/v1/messaging/message/a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890', + {}, + {'header': 'Header text', 'body': 'Body text', 'image': {'imageIdentifier': 'b2c3d4e5-f6a7-8901-b2c3-d4e5f6a78901', 'altText': 'Alt text'}}) + image = UploadMessageImage(imageIdentifier=UUID('b2c3d4e5-f6a7-8901-b2c3-d4e5f6a78901'), altText='Alt text') + upload_message_request_body = UploadMessageRequestBody(header='Header text', body='Body text', image=image) + await client.upload_message(UUID('a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890'), upload_message_request_body) + + async def test_delete_message(self): + client = self.get_client_with_body(b'', + 'DELETE', + 'https://local-testing-base-url/inApps/v1/messaging/message/a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890', + {}, + None) + await client.delete_message(UUID('a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890')) + + async def test_get_message_list(self): + client = self.get_client_with_body_from_file('tests/resources/models/getMessageListResponse.json', + 'GET', + 'https://local-testing-base-url/inApps/v1/messaging/message/list', + {}, + None) + response = await client.get_message_list() + self.assertIsNotNone(response) + self.assertEqual(1, len(response.messageIdentifiers)) + self.assertEqual(UUID('a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890'), response.messageIdentifiers[0].messageIdentifier) + self.assertEqual(MessageState.APPROVED, response.messageIdentifiers[0].messageState) + + async def test_configure_default_message(self): + client = self.get_client_with_body(b'', + 'PUT', + 'https://local-testing-base-url/inApps/v1/messaging/default/com.example.product/en-US', + {}, + {'messageIdentifier': 'a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890'}) + default_configuration_request = DefaultConfigurationRequest(messageIdentifier=UUID('a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890')) + await client.configure_default_message('com.example.product', 'en-US', default_configuration_request) + + async def test_delete_default_message(self): + client = self.get_client_with_body(b'', + 'DELETE', + 'https://local-testing-base-url/inApps/v1/messaging/default/com.example.product/en-US', + {}, + None) + await client.delete_default_message('com.example.product', 'en-US') + + async def test_get_default_message(self): + client = self.get_client_with_body_from_file('tests/resources/models/getDefaultMessageResponse.json', + 'GET', + 'https://local-testing-base-url/inApps/v1/messaging/default/com.example.product/en-US', + {}, + None) + response = await client.get_default_message('com.example.product', 'en-US') + self.assertIsNotNone(response) + self.assertEqual(UUID('a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890'), response.messageIdentifier) + + async def test_upload_image_with_image_size(self): + client = self.get_client_with_body(b'', + 'PUT', + 'https://local-testing-base-url/inApps/v1/messaging/image/a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890', + {'imageSize': ['FULL_SIZE']}, + None, + 200, + bytes([1, 2, 3]), + 'image/png') + await client.upload_image(UUID('a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890'), bytes([1, 2, 3]), ImageSize.FULL_SIZE) + + async def test_configure_realtime_url(self): + client = self.get_client_with_body(b'', + 'PUT', + 'https://local-testing-base-url/inApps/v1/messaging/realtime/url', + {}, + {'realtimeURL': 'https://example.com/realtime'}) + realtime_url_request = RealtimeUrlRequest(realtimeURL='https://example.com/realtime') + await client.configure_realtime_url(realtime_url_request) + + async def test_delete_realtime_url(self): + client = self.get_client_with_body(b'', + 'DELETE', + 'https://local-testing-base-url/inApps/v1/messaging/realtime/url', + {}, + None) + await client.delete_realtime_url() + + async def test_get_realtime_url(self): + client = self.get_client_with_body_from_file('tests/resources/models/getRealtimeUrlResponse.json', + 'GET', + 'https://local-testing-base-url/inApps/v1/messaging/realtime/url', + {}, + None) + response = await client.get_realtime_url() + self.assertIsNotNone(response) + self.assertEqual('https://example.com/realtime', response.realtimeURL) + + async def test_upload_message_with_bullet_points(self): + client = self.get_client_with_body(b'', + 'PUT', + 'https://local-testing-base-url/inApps/v1/messaging/message/a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890', + {}, + {'header': 'Header text', 'body': 'Body text', + 'image': {'imageIdentifier': 'b2c3d4e5-f6a7-8901-b2c3-d4e5f6a78901', 'altText': 'Alt text'}, + 'headerPosition': 'ABOVE_IMAGE', + 'bulletPoints': [{'text': 'Bullet 1', 'imageIdentifier': 'c3d4e5f6-a7b8-9012-c3d4-e5f6a7b89012', 'altText': 'Bullet alt'}]}) + image = UploadMessageImage(imageIdentifier=UUID('b2c3d4e5-f6a7-8901-b2c3-d4e5f6a78901'), altText='Alt text') + bullet_point = BulletPoint(text='Bullet 1', imageIdentifier=UUID('c3d4e5f6-a7b8-9012-c3d4-e5f6a7b89012'), altText='Bullet alt') + upload_message_request_body = UploadMessageRequestBody(header='Header text', body='Body text', image=image, headerPosition=HeaderPosition.ABOVE_IMAGE, bulletPoints=[bullet_point]) + await client.upload_message(UUID('a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890'), upload_message_request_body) + + async def test_initiate_performance_test(self): + client = self.get_client_with_body_from_file('tests/resources/models/performanceTestResponse.json', + 'POST', + 'https://local-testing-base-url/inApps/v1/messaging/performanceTest', + {}, + {'originalTransactionId': '70000500092808'}) + performance_test_request = PerformanceTestRequest(originalTransactionId='70000500092808') + response = await client.initiate_performance_test(performance_test_request) + self.assertIsNotNone(response) + self.assertEqual('c4b87a1d-2e3f-4a5b-9c6d-7e8f9a0b1c2d', response.requestId) + self.assertIsNotNone(response.config) + self.assertEqual(10, response.config.maxConcurrentRequests) + self.assertEqual(100, response.config.totalRequests) + self.assertEqual(60000, response.config.totalDuration) + self.assertEqual(500, response.config.responseTimeThreshold) + self.assertEqual(95, response.config.successRateThreshold) + + async def test_get_performance_test_results(self): + client = self.get_client_with_body_from_file('tests/resources/models/performanceTestResultResponse.json', + 'GET', + 'https://local-testing-base-url/inApps/v1/messaging/performanceTest/result/c4b87a1d-2e3f-4a5b-9c6d-7e8f9a0b1c2d', + {}, + None) + response = await client.get_performance_test_results('c4b87a1d-2e3f-4a5b-9c6d-7e8f9a0b1c2d') + self.assertIsNotNone(response) + self.assertIsNotNone(response.config) + self.assertEqual(10, response.config.maxConcurrentRequests) + self.assertEqual(100, response.config.totalRequests) + self.assertEqual(60000, response.config.totalDuration) + self.assertEqual(500, response.config.responseTimeThreshold) + self.assertEqual(95, response.config.successRateThreshold) + self.assertEqual('https://example.com/retention', response.target) + self.assertEqual(PerformanceTestStatus.PASS, response.result) + self.assertEqual('PASS', response.rawResult) + self.assertEqual(98, response.successRate) + self.assertEqual(0, response.numPending) + self.assertIsNotNone(response.responseTimes) + self.assertEqual(120, response.responseTimes.average) + self.assertEqual(100, response.responseTimes.p50) + self.assertEqual(200, response.responseTimes.p90) + self.assertEqual(250, response.responseTimes.p95) + self.assertEqual(400, response.responseTimes.p99) + self.assertEqual({SendAttemptResult.TIMED_OUT: 1, SendAttemptResult.NO_RESPONSE: 1}, response.failures) + self.assertEqual({'TIMED_OUT': 1, 'NO_RESPONSE': 1}, response.rawFailures) + + async def test_get_app_transaction_info_success(self): + client = self.get_client_with_body_from_file('tests/resources/models/appTransactionInfoResponse.json', + 'GET', + 'https://local-testing-base-url/inApps/v1/transactions/appTransactions/1234', + {}, + None) + + app_transaction_info_response = await client.get_app_transaction_info('1234') + + self.assertIsNotNone(app_transaction_info_response) + self.assertEqual('signed_app_transaction_info_value', app_transaction_info_response.signedAppTransactionInfo) + + async def test_get_app_transaction_info_invalid_transaction_id(self): + client = self.get_client_with_body_from_file('tests/resources/models/invalidTransactionIdError.json', + 'GET', + 'https://local-testing-base-url/inApps/v1/transactions/appTransactions/invalid_id', + {}, + None, + 400) + try: + await client.get_app_transaction_info('invalid_id') + except APIException as e: + self.assertEqual(400, e.http_status_code) + self.assertEqual(4000006, e.raw_api_error) + self.assertEqual(APIError.INVALID_TRANSACTION_ID, e.api_error) + self.assertEqual("Invalid transaction id.", e.error_message) + return + + self.assertFalse(True) + + async def test_get_app_transaction_info_app_transaction_does_not_exist(self): + client = self.get_client_with_body_from_file('tests/resources/models/appTransactionDoesNotExistError.json', + 'GET', + 'https://local-testing-base-url/inApps/v1/transactions/appTransactions/nonexistent_id', + {}, + None, + 404) + try: + await client.get_app_transaction_info('nonexistent_id') + except APIException as e: + self.assertEqual(404, e.http_status_code) + self.assertEqual(4040019, e.raw_api_error) + self.assertEqual(APIError.APP_TRANSACTION_DOES_NOT_EXIST_ERROR, e.api_error) + self.assertEqual("No AppTransaction exists for the customer.", e.error_message) + return + + self.assertFalse(True) + + async def test_get_app_transaction_info_transaction_id_not_found(self): + client = self.get_client_with_body_from_file('tests/resources/models/transactionIdNotFoundError.json', + 'GET', + 'https://local-testing-base-url/inApps/v1/transactions/appTransactions/not_found_id', + {}, + None, + 404) + try: + await client.get_app_transaction_info('not_found_id') + except APIException as e: + self.assertEqual(404, e.http_status_code) + self.assertEqual(4040010, e.raw_api_error) + self.assertEqual(APIError.TRANSACTION_ID_NOT_FOUND, e.api_error) + self.assertEqual("Transaction id not found.", e.error_message) + return + + self.assertFalse(True) + + async def test_finish_transaction(self): + client = self.get_client_with_body(b'', + 'POST', + 'https://local-testing-base-url/inApps/v1/transactions/1234/finish', + {}, + None) + await client.finish_transaction('1234') + + def get_signing_key(self): + return read_data_from_binary_file('tests/resources/certs/testSigningKey.p8') + + def get_client_with_body(self, body: str, expected_method: str, expected_url: str, expected_params: Dict[str, Union[str, List[str]]], expected_json: Dict[str, Any], status_code: int = 200, expected_data: bytes = None, expected_content_type: str = None): + signing_key = self.get_signing_key() + client = AsyncAppStoreServerAPIClient(signing_key, 'keyId', 'issuerId', 'com.example', Environment.LOCAL_TESTING) + async def fake_execute_and_validate_inputs(method: bytes, url: str, params: Dict[str, Union[str, List[str]]], headers: Dict[str, str], json: Dict[str, Any], data: bytes): + self.assertEqual(expected_method, method) + self.assertEqual(expected_url, url) + self.assertEqual(expected_params, params) + self.assertTrue(headers['User-Agent'].startswith('app-store-server-library/python')) + self.assertTrue(headers['Authorization'].startswith('Bearer ')) + self.assertEqual('application/json', headers['Accept']) + decoded_jwt = decode_json_from_signed_date(headers['Authorization'][7:]) + self.assertEqual('appstoreconnect-v1', decoded_jwt['payload']['aud']) + self.assertEqual('issuerId', decoded_jwt['payload']['iss']) + self.assertEqual('keyId', decoded_jwt['header']['kid']) + self.assertEqual('com.example', decoded_jwt['payload']['bid']) + + # Content-specific validation + if expected_data is not None: + self.assertEqual(['User-Agent', 'Authorization', 'Accept', 'Content-Type'], list(headers.keys())) + self.assertEqual(expected_content_type, headers['Content-Type']) + self.assertIsNone(json) + self.assertEqual(expected_data, data) + else: + self.assertEqual(['User-Agent', 'Authorization', 'Accept'], list(headers.keys())) + self.assertEqual(expected_json, json) + + response = Response(status_code, headers={'Content-Type': 'application/json'}, content=body) + return response + + client._execute_request = fake_execute_and_validate_inputs + return client + + def get_client_with_body_from_file(self, path: str, expected_method: str, expected_url: str, expected_params: Dict[str, Union[str, List[str]]], expected_json: Dict[str, Any], status_code: int = 200): + body = read_data_from_binary_file(path) + return self.get_client_with_body(body, expected_method, expected_url, expected_params, expected_json, status_code) diff --git a/tests/test_app_data.py b/tests/test_app_data.py new file mode 100644 index 00000000..dd990290 --- /dev/null +++ b/tests/test_app_data.py @@ -0,0 +1,23 @@ +# Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +import json +import unittest + +from appstoreserverlibrary.models.AppData import AppData +from appstoreserverlibrary.models.Environment import Environment +from appstoreserverlibrary.models.LibraryUtility import _get_cattrs_converter +from tests.util import read_data_from_file + + +class AppDataTest(unittest.TestCase): + def test_app_data_deserialization(self): + json_data = read_data_from_file('tests/resources/models/appData.json') + + app_data_dict = json.loads(json_data) + app_data = _get_cattrs_converter(AppData).structure(app_data_dict, AppData) + + self.assertEqual(987654321, app_data.appAppleId) + self.assertEqual("com.example", app_data.bundleId) + self.assertEqual(Environment.SANDBOX, app_data.environment) + self.assertEqual("Sandbox", app_data.rawEnvironment) + self.assertEqual("signed-app-transaction-info", app_data.signedAppTransactionInfo) diff --git a/tests/test_decoded_payloads.py b/tests/test_decoded_payloads.py index 237037df..20232480 100644 --- a/tests/test_decoded_payloads.py +++ b/tests/test_decoded_payloads.py @@ -1,7 +1,11 @@ # Copyright (c) 2023 Apple Inc. Licensed under MIT License. +from typing import Optional import unittest +from uuid import UUID from appstoreserverlibrary.models.AutoRenewStatus import AutoRenewStatus +from appstoreserverlibrary.models.BillingPlanType import BillingPlanType +from appstoreserverlibrary.models.ConsumptionRequestReason import ConsumptionRequestReason from appstoreserverlibrary.models.Environment import Environment from appstoreserverlibrary.models.ExpirationIntent import ExpirationIntent from appstoreserverlibrary.models.InAppOwnershipType import InAppOwnershipType @@ -9,13 +13,20 @@ from appstoreserverlibrary.models.OfferDiscountType import OfferDiscountType from appstoreserverlibrary.models.OfferType import OfferType from appstoreserverlibrary.models.PriceIncreaseStatus import PriceIncreaseStatus +from appstoreserverlibrary.models.PurchasePlatform import PurchasePlatform +from appstoreserverlibrary.models.RenewalBillingPlanType import RenewalBillingPlanType from appstoreserverlibrary.models.RevocationReason import RevocationReason +from appstoreserverlibrary.models.RevocationType import RevocationType from appstoreserverlibrary.models.Status import Status from appstoreserverlibrary.models.Subtype import Subtype from appstoreserverlibrary.models.TransactionReason import TransactionReason from appstoreserverlibrary.models.Type import Type +from appstoreserverlibrary.models.AdvancedCommercePeriod import AdvancedCommercePeriod +from appstoreserverlibrary.models.AdvancedCommercePriceIncreaseInfoStatus import AdvancedCommercePriceIncreaseInfoStatus +from appstoreserverlibrary.models.AdvancedCommerceRefundReason import AdvancedCommerceRefundReason +from appstoreserverlibrary.models.AdvancedCommerceRefundType import AdvancedCommerceRefundType -from tests.util import create_signed_data_from_json, get_default_signed_data_verifier +from tests.util import create_signed_data_from_json, get_default_signed_data_verifier, get_signed_data_verifier class DecodedPayloads(unittest.TestCase): def test_app_transaction_decoding(self): @@ -36,6 +47,9 @@ def test_app_transaction_decoding(self): self.assertEqual("device_verification_value", app_transaction.deviceVerification) self.assertEqual("48ccfa42-7431-4f22-9908-7e88983e105a", app_transaction.deviceVerificationNonce) self.assertEqual(1698148700000, app_transaction.preorderDate) + self.assertEqual("71134", app_transaction.appTransactionId) + self.assertEqual(PurchasePlatform.IOS, app_transaction.originalPlatform) + self.assertEqual("iOS", app_transaction.rawOriginalPlatform) def test_transaction_decoding(self): signed_transaction = create_signed_data_from_json('tests/resources/models/signedTransaction.json') @@ -77,6 +91,92 @@ def test_transaction_decoding(self): self.assertEqual("USD", transaction.currency) self.assertEqual(OfferDiscountType.PAY_AS_YOU_GO, transaction.offerDiscountType) self.assertEqual("PAY_AS_YOU_GO", transaction.rawOfferDiscountType) + self.assertEqual("71134", transaction.appTransactionId) + self.assertEqual("P1Y", transaction.offerPeriod) + self.assertIsNotNone(transaction.advancedCommerceInfo) + info = transaction.advancedCommerceInfo + self.assertIsNotNone(info.descriptors) + self.assertEqual("Premium Plan", info.descriptors.description) + self.assertEqual("Premium", info.descriptors.displayName) + self.assertEqual(1500, info.estimatedTax) + self.assertEqual(AdvancedCommercePeriod.P1M, info.period) + self.assertEqual("P1M", info.rawPeriod) + self.assertEqual("ref-12345", info.requestReferenceId) + self.assertEqual("TAX_CODE_1", info.taxCode) + self.assertEqual(8490, info.taxExclusivePrice) + self.assertEqual("0.15", info.taxRate) + self.assertIsNotNone(info.items) + self.assertEqual(1, len(info.items)) + acItem = info.items[0] + self.assertEqual("com.example.sku.premium", acItem.SKU) + self.assertEqual("Premium feature", acItem.description) + self.assertEqual("Premium Feature", acItem.displayName) + self.assertEqual(9990, acItem.price) + self.assertEqual(1698149200000, acItem.revocationDate) + self.assertIsNotNone(acItem.refunds) + self.assertEqual(1, len(acItem.refunds)) + refund = acItem.refunds[0] + self.assertEqual(5000, refund.refundAmount) + self.assertEqual(1698149100000, refund.refundDate) + self.assertEqual(AdvancedCommerceRefundReason.FULFILLMENT_ISSUE, refund.refundReason) + self.assertEqual("FULFILLMENT_ISSUE", refund.rawRefundReason) + self.assertEqual(AdvancedCommerceRefundType.PRORATED, refund.refundType) + self.assertEqual("PRORATED", refund.rawRefundType) + self.assertEqual(BillingPlanType.MONTHLY, transaction.billingPlanType) + self.assertEqual("MONTHLY", transaction.rawBillingPlanType) + self.assertIsNotNone(transaction.commitmentInfo) + commitment = transaction.commitmentInfo + self.assertEqual(3, commitment.billingPeriodNumber) + self.assertEqual(1698150000000, commitment.commitmentExpiresDate) + self.assertEqual(119880, commitment.commitmentPrice) + self.assertEqual(12, commitment.totalBillingPeriods) + + def test_transaction_with_revocation_decoding(self): + signed_transaction = create_signed_data_from_json('tests/resources/models/signedTransactionWithRevocation.json') + + signed_data_verifier = get_default_signed_data_verifier() + + transaction = signed_data_verifier.verify_and_decode_signed_transaction(signed_transaction) + + self.assertEqual("12345", transaction.originalTransactionId) + self.assertEqual("23456", transaction.transactionId) + self.assertEqual("34343", transaction.webOrderLineItemId) + self.assertEqual("com.example", transaction.bundleId) + self.assertEqual("com.example.product", transaction.productId) + self.assertEqual("55555", transaction.subscriptionGroupIdentifier) + self.assertEqual(1698148800000, transaction.originalPurchaseDate) + self.assertEqual(1698148900000, transaction.purchaseDate) + self.assertEqual(1698148950000, transaction.revocationDate) + self.assertEqual(1698149000000, transaction.expiresDate) + self.assertEqual(1, transaction.quantity) + self.assertEqual(Type.AUTO_RENEWABLE_SUBSCRIPTION, transaction.type) + self.assertEqual("Auto-Renewable Subscription", transaction.rawType) + self.assertEqual("7e3fb20b-4cdb-47cc-936d-99d65f608138", transaction.appAccountToken) + self.assertEqual(InAppOwnershipType.PURCHASED, transaction.inAppOwnershipType) + self.assertEqual("PURCHASED", transaction.rawInAppOwnershipType) + self.assertEqual(1698148900000, transaction.signedDate) + self.assertEqual(RevocationReason.REFUNDED_DUE_TO_ISSUE, transaction.revocationReason) + self.assertEqual(1, transaction.rawRevocationReason) + self.assertEqual("abc.123", transaction.offerIdentifier) + self.assertTrue(transaction.isUpgraded) + self.assertEqual(OfferType.INTRODUCTORY_OFFER, transaction.offerType) + self.assertEqual(1, transaction.rawOfferType) + self.assertEqual("USA", transaction.storefront) + self.assertEqual("143441", transaction.storefrontId) + self.assertEqual(TransactionReason.PURCHASE, transaction.transactionReason) + self.assertEqual("PURCHASE", transaction.rawTransactionReason) + self.assertEqual(Environment.LOCAL_TESTING, transaction.environment) + self.assertEqual("LocalTesting", transaction.rawEnvironment) + self.assertEqual(10990, transaction.price) + self.assertEqual("USD", transaction.currency) + self.assertEqual(OfferDiscountType.PAY_AS_YOU_GO, transaction.offerDiscountType) + self.assertEqual("PAY_AS_YOU_GO", transaction.rawOfferDiscountType) + self.assertEqual("71134", transaction.appTransactionId) + self.assertEqual("P1Y", transaction.offerPeriod) + self.assertEqual(RevocationType.REFUND_PRORATED, transaction.revocationType) + self.assertEqual("REFUND_PRORATED", transaction.rawRevocationType) + self.assertEqual(50000, transaction.revocationPercentage) + def test_renewal_info_decoding(self): @@ -105,8 +205,49 @@ def test_renewal_info_decoding(self): self.assertEqual("LocalTesting", renewal_info.rawEnvironment) self.assertEqual(1698148800000, renewal_info.recentSubscriptionStartDate) self.assertEqual(1698148850000, renewal_info.renewalDate) + self.assertEqual(9990, renewal_info.renewalPrice) + self.assertEqual("USD", renewal_info.currency) + self.assertEqual(OfferDiscountType.PAY_AS_YOU_GO, renewal_info.offerDiscountType) + self.assertEqual("PAY_AS_YOU_GO", renewal_info.rawOfferDiscountType) + self.assertEqual(['eligible1', 'eligible2'], renewal_info.eligibleWinBackOfferIds) + self.assertEqual("71134", renewal_info.appTransactionId) + self.assertEqual("P1Y", renewal_info.offerPeriod) + self.assertEqual("7e3fb20b-4cdb-47cc-936d-99d65f608138", renewal_info.appAccountToken) + self.assertIsNotNone(renewal_info.advancedCommerceInfo) + acInfo = renewal_info.advancedCommerceInfo + self.assertEqual("token-abc-123", acInfo.consistencyToken) + self.assertIsNotNone(acInfo.descriptors) + self.assertEqual("Premium Plan", acInfo.descriptors.description) + self.assertEqual("Premium", acInfo.descriptors.displayName) + self.assertEqual(AdvancedCommercePeriod.P1M, acInfo.period) + self.assertEqual("P1M", acInfo.rawPeriod) + self.assertEqual("ref-12345", acInfo.requestReferenceId) + self.assertEqual("TAX_CODE_1", acInfo.taxCode) + self.assertIsNotNone(acInfo.items) + self.assertEqual(1, len(acInfo.items)) + item = acInfo.items[0] + self.assertEqual("com.example.sku.premium", item.SKU) + self.assertEqual("Premium feature", item.description) + self.assertEqual("Premium Feature", item.displayName) + self.assertEqual(9990, item.price) + self.assertIsNotNone(item.priceIncreaseInfo) + self.assertEqual(["com.example.sku.1", "com.example.sku.2"], item.priceIncreaseInfo.dependentSKUs) + self.assertEqual(12990, item.priceIncreaseInfo.price) + self.assertEqual(AdvancedCommercePriceIncreaseInfoStatus.PENDING, item.priceIncreaseInfo.status) + self.assertEqual("PENDING", item.priceIncreaseInfo.rawStatus) + self.assertIsNotNone(renewal_info.commitmentInfo) + commitment = renewal_info.commitmentInfo + self.assertEqual("com.example.product.commitment", commitment.commitmentAutoRenewProductId) + self.assertEqual(AutoRenewStatus.ON, commitment.commitmentAutoRenewStatus) + self.assertEqual(1, commitment.rawCommitmentAutoRenewStatus) + self.assertEqual(RenewalBillingPlanType.MONTHLY, commitment.commitmentRenewalBillingPlanType) + self.assertEqual("MONTHLY", commitment.rawCommitmentRenewalBillingPlanType) + self.assertEqual(1698149500000, commitment.commitmentRenewalDate) + self.assertEqual(9990, commitment.commitmentRenewalPrice) + self.assertEqual(RenewalBillingPlanType.MONTHLY, renewal_info.renewalBillingPlanType) + self.assertEqual("MONTHLY", renewal_info.rawRenewalBillingPlanType) - def test_notificaiton_decoding(self): + def test_notification_decoding(self): signed_notification = create_signed_data_from_json('tests/resources/models/signedNotification.json') signed_data_verifier = get_default_signed_data_verifier() @@ -122,6 +263,7 @@ def test_notificaiton_decoding(self): self.assertEqual(1698148900000, notification.signedDate) self.assertIsNotNone(notification.data) self.assertIsNone(notification.summary) + self.assertIsNone(notification.externalPurchaseToken) self.assertEqual(Environment.LOCAL_TESTING, notification.data.environment) self.assertEqual("LocalTesting", notification.data.rawEnvironment) self.assertEqual(41234, notification.data.appAppleId) @@ -131,6 +273,37 @@ def test_notificaiton_decoding(self): self.assertEqual("signed_renewal_info_value", notification.data.signedRenewalInfo); self.assertEqual(Status.ACTIVE, notification.data.status) self.assertEqual(1, notification.data.rawStatus) + self.assertIsNone(notification.data.consumptionRequestReason) + self.assertIsNone(notification.data.rawConsumptionRequestReason) + + def test_consumption_request_notification_decoding(self): + signed_notification = create_signed_data_from_json('tests/resources/models/signedConsumptionRequestNotification.json') + + signed_data_verifier = get_default_signed_data_verifier() + + notification = signed_data_verifier.verify_and_decode_notification(signed_notification) + + self.assertEqual(NotificationTypeV2.CONSUMPTION_REQUEST, notification.notificationType) + self.assertEqual("CONSUMPTION_REQUEST", notification.rawNotificationType) + self.assertIsNone(notification.subtype) + self.assertIsNone(notification.rawSubtype) + self.assertEqual("002e14d5-51f5-4503-b5a8-c3a1af68eb20", notification.notificationUUID) + self.assertEqual("2.0", notification.version) + self.assertEqual(1698148900000, notification.signedDate) + self.assertIsNotNone(notification.data) + self.assertIsNone(notification.summary) + self.assertIsNone(notification.externalPurchaseToken) + self.assertEqual(Environment.LOCAL_TESTING, notification.data.environment) + self.assertEqual("LocalTesting", notification.data.rawEnvironment) + self.assertEqual(41234, notification.data.appAppleId) + self.assertEqual("com.example", notification.data.bundleId) + self.assertEqual("1.2.3", notification.data.bundleVersion) + self.assertEqual("signed_transaction_info_value", notification.data.signedTransactionInfo) + self.assertEqual("signed_renewal_info_value", notification.data.signedRenewalInfo); + self.assertEqual(Status.ACTIVE, notification.data.status) + self.assertEqual(1, notification.data.rawStatus) + self.assertEqual(ConsumptionRequestReason.UNINTENDED_PURCHASE, notification.data.consumptionRequestReason) + self.assertEqual("UNINTENDED_PURCHASE", notification.data.rawConsumptionRequestReason) def test_summary_notification_decoding(self): signed_summary_notification = create_signed_data_from_json('tests/resources/models/signedSummaryNotification.json') @@ -148,6 +321,7 @@ def test_summary_notification_decoding(self): self.assertEqual(1698148900000, notification.signedDate) self.assertIsNone(notification.data) self.assertIsNotNone(notification.summary) + self.assertIsNone(notification.externalPurchaseToken) self.assertEqual(Environment.LOCAL_TESTING, notification.summary.environment) self.assertEqual("LocalTesting", notification.summary.rawEnvironment) self.assertEqual(41234, notification.summary.appAppleId) @@ -156,4 +330,101 @@ def test_summary_notification_decoding(self): self.assertEqual("efb27071-45a4-4aca-9854-2a1e9146f265", notification.summary.requestIdentifier) self.assertEqual(["CAN", "USA", "MEX"], notification.summary.storefrontCountryCodes) self.assertEqual(5, notification.summary.succeededCount) - self.assertEqual(2, notification.summary.failedCount) \ No newline at end of file + self.assertEqual(2, notification.summary.failedCount) + + def test_external_purchase_token_notification_decoding(self): + signed_external_purchase_token_notification = create_signed_data_from_json('tests/resources/models/signedExternalPurchaseTokenNotification.json') + + signed_data_verifier = get_default_signed_data_verifier() + + def check_environment_and_bundle_id(bundle_id: Optional[str], app_apple_id: Optional[int], environment: Optional[Environment]): + self.assertEqual("com.example", bundle_id) + self.assertEqual(55555, app_apple_id) + self.assertEqual(Environment.PRODUCTION, environment) + + signed_data_verifier._verify_notification = check_environment_and_bundle_id + + notification = signed_data_verifier.verify_and_decode_notification(signed_external_purchase_token_notification) + + self.assertEqual(NotificationTypeV2.EXTERNAL_PURCHASE_TOKEN, notification.notificationType) + self.assertEqual("EXTERNAL_PURCHASE_TOKEN", notification.rawNotificationType) + self.assertEqual(Subtype.UNREPORTED, notification.subtype) + self.assertEqual("UNREPORTED", notification.rawSubtype) + self.assertEqual("002e14d5-51f5-4503-b5a8-c3a1af68eb20", notification.notificationUUID) + self.assertEqual("2.0", notification.version) + self.assertEqual(1698148900000, notification.signedDate) + self.assertIsNone(notification.data) + self.assertIsNone(notification.summary) + self.assertIsNotNone(notification.externalPurchaseToken) + self.assertEqual("b2158121-7af9-49d4-9561-1f588205523e", notification.externalPurchaseToken.externalPurchaseId) + self.assertEqual(1698148950000, notification.externalPurchaseToken.tokenCreationDate) + self.assertEqual(55555, notification.externalPurchaseToken.appAppleId) + self.assertEqual("com.example", notification.externalPurchaseToken.bundleId) + + def test_external_purchase_token_sandbox_notification_decoding(self): + signed_external_purchase_token_notification = create_signed_data_from_json('tests/resources/models/signedExternalPurchaseTokenSandboxNotification.json') + + signed_data_verifier = get_default_signed_data_verifier() + + def check_environment_and_bundle_id(bundle_id: Optional[str], app_apple_id: Optional[int], environment: Optional[Environment]): + self.assertEqual("com.example", bundle_id) + self.assertEqual(55555, app_apple_id) + self.assertEqual(Environment.SANDBOX, environment) + + signed_data_verifier._verify_notification = check_environment_and_bundle_id + + notification = signed_data_verifier.verify_and_decode_notification(signed_external_purchase_token_notification) + + self.assertEqual(NotificationTypeV2.EXTERNAL_PURCHASE_TOKEN, notification.notificationType) + self.assertEqual("EXTERNAL_PURCHASE_TOKEN", notification.rawNotificationType) + self.assertEqual(Subtype.UNREPORTED, notification.subtype) + self.assertEqual("UNREPORTED", notification.rawSubtype) + self.assertEqual("002e14d5-51f5-4503-b5a8-c3a1af68eb20", notification.notificationUUID) + self.assertEqual("2.0", notification.version) + self.assertEqual(1698148900000, notification.signedDate) + self.assertIsNone(notification.data) + self.assertIsNone(notification.summary) + self.assertIsNotNone(notification.externalPurchaseToken) + self.assertEqual("SANDBOX_b2158121-7af9-49d4-9561-1f588205523e", notification.externalPurchaseToken.externalPurchaseId) + self.assertEqual(1698148950000, notification.externalPurchaseToken.tokenCreationDate) + self.assertEqual(55555, notification.externalPurchaseToken.appAppleId) + self.assertEqual("com.example", notification.externalPurchaseToken.bundleId) + + def test_realtime_request_decoding(self): + signed_realtime_request = create_signed_data_from_json('tests/resources/models/decodedRealtimeRequest.json') + + signed_data_verifier = get_default_signed_data_verifier() + request = signed_data_verifier.verify_and_decode_realtime_request(signed_realtime_request) + + self.assertEqual('99371282', request.originalTransactionId) + self.assertEqual(531412, request.appAppleId) + self.assertEqual('com.example.product', request.productId) + self.assertEqual('en-US', request.userLocale) + self.assertEqual(UUID('3db5c98d-8acf-4e29-831e-8e1f82f9f6e9'), request.requestIdentifier) + self.assertEqual(Environment.LOCAL_TESTING, request.environment) + self.assertEqual('LocalTesting', request.rawEnvironment) + self.assertEqual(1698148900000, request.signedDate) + + def test_rescind_consent_notification_decoding(self): + signed_notification = create_signed_data_from_json('tests/resources/models/signedRescindConsentNotification.json') + + signed_data_verifier = get_default_signed_data_verifier() + + notification = signed_data_verifier.verify_and_decode_notification(signed_notification) + + self.assertEqual(NotificationTypeV2.RESCIND_CONSENT, notification.notificationType) + self.assertEqual("RESCIND_CONSENT", notification.rawNotificationType) + self.assertIsNone(notification.subtype) + self.assertIsNone(notification.rawSubtype) + self.assertEqual("002e14d5-51f5-4503-b5a8-c3a1af68eb20", notification.notificationUUID) + self.assertEqual("2.0", notification.version) + self.assertEqual(1698148900000, notification.signedDate) + self.assertIsNone(notification.data) + self.assertIsNone(notification.summary) + self.assertIsNone(notification.externalPurchaseToken) + self.assertIsNotNone(notification.appData) + self.assertEqual(Environment.LOCAL_TESTING, notification.appData.environment) + self.assertEqual("LocalTesting", notification.appData.rawEnvironment) + self.assertEqual(41234, notification.appData.appAppleId) + self.assertEqual("com.example", notification.appData.bundleId) + self.assertEqual("signed_app_transaction_info_value", notification.appData.signedAppTransactionInfo) diff --git a/tests/test_jws_signature_creator.py b/tests/test_jws_signature_creator.py new file mode 100644 index 00000000..857c83a3 --- /dev/null +++ b/tests/test_jws_signature_creator.py @@ -0,0 +1,128 @@ +# Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +from attr import define +import attr +import base64 +import json +import jwt +import unittest +from appstoreserverlibrary.jws_signature_creator import AdvancedCommerceAPIInAppRequest, AdvancedCommerceAPIInAppSignatureCreator, IntroductoryOfferEligibilitySignatureCreator, PromotionalOfferV2SignatureCreator +from tests.util import read_data_from_binary_file + +@define +class TestInAppRequest(AdvancedCommerceAPIInAppRequest): + test_value: str + +class JWSSignatureCreatorTest(unittest.TestCase): + def test_promotional_offer_signature_creator(self): + signing_key = read_data_from_binary_file('tests/resources/certs/testSigningKey.p8') + signature_creator = PromotionalOfferV2SignatureCreator(signing_key, 'keyId', 'issuerId', 'bundleId') + signature = signature_creator.create_signature("productId", "offerIdentifier", "transactionId") + self.assertIsNotNone(signature) + headers = jwt.get_unverified_header(signature) + payload = jwt.decode(signature, options={"verify_signature": False}) + + # Header + self.assertEqual("JWT", headers["typ"]) + self.assertEqual("ES256", headers["alg"]) + self.assertEqual("keyId", headers["kid"]) + # Payload + self.assertEqual("issuerId", payload['iss']) + self.assertIsNotNone(payload['iat']) + self.assertFalse('exp' in payload) + self.assertEqual("promotional-offer", payload['aud']) + self.assertEqual('bundleId', payload['bid']) + self.assertIsNotNone(payload['nonce']) + self.assertEqual('productId', payload['productId']) + self.assertEqual('offerIdentifier', payload['offerIdentifier']) + self.assertEqual('transactionId', payload['transactionId']) + + def test_promotional_offer_signature_creator_transaction_id_missing(self): + signing_key = read_data_from_binary_file('tests/resources/certs/testSigningKey.p8') + signature_creator = PromotionalOfferV2SignatureCreator(signing_key, 'keyId', 'issuerId', 'bundleId') + signature = signature_creator.create_signature("productId", "offerIdentifier", None) + payload = jwt.decode(signature, options={"verify_signature": False}) + self.assertFalse('transactionId' in payload) + + def test_promotional_offer_signature_creator_offer_identifier_missing(self): + signing_key = read_data_from_binary_file('tests/resources/certs/testSigningKey.p8') + signature_creator = PromotionalOfferV2SignatureCreator(signing_key, 'keyId', 'issuerId', 'bundleId') + with self.assertRaises(ValueError): + signature_creator.create_signature("productId", None, "transactionId") + + def test_promotional_offer_signature_creator_product_id_missing(self): + signing_key = read_data_from_binary_file('tests/resources/certs/testSigningKey.p8') + signature_creator = PromotionalOfferV2SignatureCreator(signing_key, 'keyId', 'issuerId', 'bundleId') + with self.assertRaises(ValueError): + signature_creator.create_signature(None, "offerIdentifier", "transactionId") + + def test_introductory_offer_eligibility_signature_creator(self): + signing_key = read_data_from_binary_file('tests/resources/certs/testSigningKey.p8') + signature_creator = IntroductoryOfferEligibilitySignatureCreator(signing_key, 'keyId', 'issuerId', 'bundleId') + signature = signature_creator.create_signature("productId", True, "transactionId") + self.assertIsNotNone(signature) + headers = jwt.get_unverified_header(signature) + payload = jwt.decode(signature, options={"verify_signature": False}) + + # Header + self.assertEqual("JWT", headers["typ"]) + self.assertEqual("ES256", headers["alg"]) + self.assertEqual("keyId", headers["kid"]) + # Payload + self.assertEqual("issuerId", payload['iss']) + self.assertIsNotNone(payload['iat']) + self.assertFalse('exp' in payload) + self.assertEqual("introductory-offer-eligibility", payload['aud']) + self.assertEqual('bundleId', payload['bid']) + self.assertIsNotNone(payload['nonce']) + self.assertEqual('productId', payload['productId']) + self.assertEqual(True, payload['allowIntroductoryOffer']) + self.assertEqual('transactionId', payload['transactionId']) + + def test_introductory_offer_eligibility_signature_creator_transaction_id_missing(self): + signing_key = read_data_from_binary_file('tests/resources/certs/testSigningKey.p8') + signature_creator = IntroductoryOfferEligibilitySignatureCreator(signing_key, 'keyId', 'issuerId', 'bundleId') + with self.assertRaises(ValueError): + signature_creator.create_signature("productId", True, None) + + def test_introductory_offer_eligibility_signature_creator_allow_introductory_offer_missing(self): + signing_key = read_data_from_binary_file('tests/resources/certs/testSigningKey.p8') + signature_creator = IntroductoryOfferEligibilitySignatureCreator(signing_key, 'keyId', 'issuerId', 'bundleId') + with self.assertRaises(ValueError): + signature_creator.create_signature("productId", None, "transactionId") + + def test_introductory_offer_eligibility_signature_creator_product_id_missing(self): + signing_key = read_data_from_binary_file('tests/resources/certs/testSigningKey.p8') + signature_creator = IntroductoryOfferEligibilitySignatureCreator(signing_key, 'keyId', 'issuerId', 'bundleId') + with self.assertRaises(ValueError): + signature_creator.create_signature(None, True, "transactionId") + + def test_advanced_commerce_api_in_app_signature_creator(self): + signing_key = read_data_from_binary_file('tests/resources/certs/testSigningKey.p8') + signature_creator = AdvancedCommerceAPIInAppSignatureCreator(signing_key, 'keyId', 'issuerId', 'bundleId') + request = TestInAppRequest("testValue") + signature = signature_creator.create_signature(request) + self.assertIsNotNone(signature) + headers = jwt.get_unverified_header(signature) + payload = jwt.decode(signature, options={"verify_signature": False}) + + # Header + self.assertEqual("JWT", headers["typ"]) + self.assertEqual("ES256", headers["alg"]) + self.assertEqual("keyId", headers["kid"]) + # Payload + self.assertEqual("issuerId", payload['iss']) + self.assertIsNotNone(payload['iat']) + self.assertFalse('exp' in payload) + self.assertEqual("advanced-commerce-api", payload['aud']) + self.assertEqual('bundleId', payload['bid']) + self.assertIsNotNone(payload['nonce']) + request = payload['request'] + decode_json = json.loads(str(base64.b64decode(request).decode('utf-8'))) + self.assertEqual('testValue', decode_json['test_value']) + + def test_advanced_commerce_api_in_app_signature_creator_request_missing(self): + signing_key = read_data_from_binary_file('tests/resources/certs/testSigningKey.p8') + signature_creator = AdvancedCommerceAPIInAppSignatureCreator(signing_key, 'keyId', 'issuerId', 'bundleId') + with self.assertRaises(ValueError): + signature_creator.create_signature(None) \ No newline at end of file diff --git a/tests/test_promotional_offer_signature_creator.py b/tests/test_promotional_offer_signature_creator.py index 31cdb845..3b73310a 100644 --- a/tests/test_promotional_offer_signature_creator.py +++ b/tests/test_promotional_offer_signature_creator.py @@ -11,5 +11,5 @@ class PromotionalOfferSignatureCreatorTest(unittest.TestCase): def test_signature_creator(self): signing_key = read_data_from_binary_file('tests/resources/certs/testSigningKey.p8') signature_creator = PromotionalOfferSignatureCreator(signing_key, 'keyId', 'bundleId') - signature = signature_creator.create_signature("productId", "offerId", "applicationUsername", UUID("20fba8a0-2b80-4a7d-a17f-85c1854727f8"), 1698148900000) + signature = signature_creator.create_signature("productId", "offerId", "appAccountToken", UUID("20fba8a0-2b80-4a7d-a17f-85c1854727f8"), 1698148900000) self.assertIsNotNone(signature) \ No newline at end of file diff --git a/tests/test_retention_messaging.py b/tests/test_retention_messaging.py new file mode 100644 index 00000000..45b66f67 --- /dev/null +++ b/tests/test_retention_messaging.py @@ -0,0 +1,201 @@ +# Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +import unittest +from uuid import UUID + +from appstoreserverlibrary.models.AlternateProduct import AlternateProduct +from appstoreserverlibrary.models.AdvancedCommerceInfo import AdvancedCommerceInfo +from appstoreserverlibrary.models.BillingPlanType import BillingPlanType +from appstoreserverlibrary.models.DecodedRealtimeRequestBody import DecodedRealtimeRequestBody +from appstoreserverlibrary.models.Environment import Environment +from appstoreserverlibrary.models.Message import Message +from appstoreserverlibrary.models.PromotionalOffer import PromotionalOffer +from appstoreserverlibrary.models.PromotionalOfferSignatureV1 import PromotionalOfferSignatureV1 +from appstoreserverlibrary.models.RealtimeResponseBody import RealtimeResponseBody +from appstoreserverlibrary.models.LibraryUtility import _get_cattrs_converter + + +class RetentionMessaging(unittest.TestCase): + def test_realtime_response_body_with_message(self): + # Create a RealtimeResponseBody with a Message + message_id = UUID('a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890') + message = Message(messageIdentifier=message_id) + response_body = RealtimeResponseBody(message=message) + + # Serialize to dict + c = _get_cattrs_converter(RealtimeResponseBody) + json_dict = c.unstructure(response_body) + + # Validate structure + self.assertIn('message', json_dict) + self.assertIn('messageIdentifier', json_dict['message']) + self.assertEqual('a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890', json_dict['message']['messageIdentifier']) + self.assertNotIn('alternateProduct', json_dict) + self.assertNotIn('promotionalOffer', json_dict) + + # Deserialize back + deserialized = c.structure(json_dict, RealtimeResponseBody) + + # Verify + self.assertIsNotNone(deserialized.message) + self.assertEqual(message_id, deserialized.message.messageIdentifier) + self.assertIsNone(deserialized.alternateProduct) + self.assertIsNone(deserialized.promotionalOffer) + + def test_realtime_response_body_with_alternate_product(self): + # Create a RealtimeResponseBody with an AlternateProduct + message_id = UUID('b2c3d4e5-f6a7-8901-b2c3-d4e5f6a78901') + product_id = 'com.example.alternate.product' + alternate_product = AlternateProduct(messageIdentifier=message_id, productId=product_id, billingPlanType=BillingPlanType.MONTHLY) + response_body = RealtimeResponseBody(alternateProduct=alternate_product) + + # Serialize to dict + c = _get_cattrs_converter(RealtimeResponseBody) + json_dict = c.unstructure(response_body) + + # Validate structure + self.assertIn('alternateProduct', json_dict) + self.assertIn('messageIdentifier', json_dict['alternateProduct']) + self.assertIn('productId', json_dict['alternateProduct']) + self.assertEqual('b2c3d4e5-f6a7-8901-b2c3-d4e5f6a78901', json_dict['alternateProduct']['messageIdentifier']) + self.assertEqual('com.example.alternate.product', json_dict['alternateProduct']['productId']) + self.assertEqual('MONTHLY', json_dict['alternateProduct']['billingPlanType']) + self.assertNotIn('message', json_dict) + self.assertNotIn('promotionalOffer', json_dict) + + # Deserialize back + deserialized = c.structure(json_dict, RealtimeResponseBody) + + # Verify + self.assertIsNone(deserialized.message) + self.assertIsNotNone(deserialized.alternateProduct) + self.assertEqual(message_id, deserialized.alternateProduct.messageIdentifier) + self.assertEqual(product_id, deserialized.alternateProduct.productId) + self.assertEqual(BillingPlanType.MONTHLY, deserialized.alternateProduct.billingPlanType) + self.assertEqual("MONTHLY", deserialized.alternateProduct.rawBillingPlanType) + self.assertIsNone(deserialized.promotionalOffer) + + def test_realtime_response_body_with_promotional_offer_v2(self): + # Create a RealtimeResponseBody with a PromotionalOffer (V2 signature) + message_id = UUID('c3d4e5f6-a789-0123-c3d4-e5f6a7890123') + signature_v2 = 'signature2' + promotional_offer = PromotionalOffer(messageIdentifier=message_id, promotionalOfferSignatureV2=signature_v2) + response_body = RealtimeResponseBody(promotionalOffer=promotional_offer) + + # Serialize to dict + c = _get_cattrs_converter(RealtimeResponseBody) + json_dict = c.unstructure(response_body) + + # Validate structure + self.assertIn('promotionalOffer', json_dict) + self.assertIn('messageIdentifier', json_dict['promotionalOffer']) + self.assertIn('promotionalOfferSignatureV2', json_dict['promotionalOffer']) + self.assertEqual('c3d4e5f6-a789-0123-c3d4-e5f6a7890123', json_dict['promotionalOffer']['messageIdentifier']) + self.assertEqual('signature2', json_dict['promotionalOffer']['promotionalOfferSignatureV2']) + self.assertNotIn('promotionalOfferSignatureV1', json_dict['promotionalOffer']) + self.assertNotIn('message', json_dict) + self.assertNotIn('alternateProduct', json_dict) + + # Deserialize back + deserialized = c.structure(json_dict, RealtimeResponseBody) + + # Verify + self.assertIsNone(deserialized.message) + self.assertIsNone(deserialized.alternateProduct) + self.assertIsNotNone(deserialized.promotionalOffer) + self.assertEqual(message_id, deserialized.promotionalOffer.messageIdentifier) + self.assertEqual(signature_v2, deserialized.promotionalOffer.promotionalOfferSignatureV2) + self.assertIsNone(deserialized.promotionalOffer.promotionalOfferSignatureV1) + + def test_realtime_response_body_with_promotional_offer_v1(self): + # Create a RealtimeResponseBody with a PromotionalOffer (V1 signature) + message_id = UUID('d4e5f6a7-8901-2345-d4e5-f6a789012345') + nonce = UUID('e5f6a789-0123-4567-e5f6-a78901234567') + app_account_token = UUID('f6a78901-2345-6789-f6a7-890123456789') + signature_v1 = PromotionalOfferSignatureV1( + encodedSignature='base64encodedSignature', + productId='com.example.product', + nonce=nonce, + timestamp=1698148900000, + keyId='keyId123', + offerIdentifier='offer123', + appAccountToken=app_account_token + ) + + promotional_offer = PromotionalOffer(messageIdentifier=message_id, promotionalOfferSignatureV1=signature_v1) + response_body = RealtimeResponseBody(promotionalOffer=promotional_offer) + + # Serialize to dict + c = _get_cattrs_converter(RealtimeResponseBody) + json_dict = c.unstructure(response_body) + + # Validate structure + self.assertIn('promotionalOffer', json_dict) + self.assertIn('messageIdentifier', json_dict['promotionalOffer']) + self.assertIn('promotionalOfferSignatureV1', json_dict['promotionalOffer']) + self.assertEqual('d4e5f6a7-8901-2345-d4e5-f6a789012345', json_dict['promotionalOffer']['messageIdentifier']) + + v1_node = json_dict['promotionalOffer']['promotionalOfferSignatureV1'] + self.assertIn('encodedSignature', v1_node) + self.assertIn('productId', v1_node) + self.assertIn('nonce', v1_node) + self.assertIn('timestamp', v1_node) + self.assertIn('keyId', v1_node) + self.assertIn('offerIdentifier', v1_node) + self.assertIn('appAccountToken', v1_node) + self.assertEqual('base64encodedSignature', v1_node['encodedSignature']) + self.assertEqual('com.example.product', v1_node['productId']) + self.assertEqual('e5f6a789-0123-4567-e5f6-a78901234567', v1_node['nonce']) + self.assertEqual(1698148900000, v1_node['timestamp']) + self.assertEqual('keyId123', v1_node['keyId']) + self.assertEqual('offer123', v1_node['offerIdentifier']) + self.assertEqual('f6a78901-2345-6789-f6a7-890123456789', v1_node['appAccountToken']) + + self.assertNotIn('promotionalOfferSignatureV2', json_dict['promotionalOffer']) + self.assertNotIn('message', json_dict) + self.assertNotIn('alternateProduct', json_dict) + + # Deserialize back + deserialized = c.structure(json_dict, RealtimeResponseBody) + + # Verify + self.assertIsNone(deserialized.message) + self.assertIsNone(deserialized.alternateProduct) + self.assertIsNotNone(deserialized.promotionalOffer) + self.assertEqual(message_id, deserialized.promotionalOffer.messageIdentifier) + self.assertIsNone(deserialized.promotionalOffer.promotionalOfferSignatureV2) + self.assertIsNotNone(deserialized.promotionalOffer.promotionalOfferSignatureV1) + + deserialized_v1 = deserialized.promotionalOffer.promotionalOfferSignatureV1 + self.assertEqual('com.example.product', deserialized_v1.productId) + self.assertEqual('offer123', deserialized_v1.offerIdentifier) + self.assertEqual(nonce, deserialized_v1.nonce) + self.assertEqual(1698148900000, deserialized_v1.timestamp) + self.assertEqual('keyId123', deserialized_v1.keyId) + self.assertEqual(app_account_token, deserialized_v1.appAccountToken) + self.assertEqual('base64encodedSignature', deserialized_v1.encodedSignature) + + def test_realtime_response_body_with_advanced_commerce_info(self): + message_id = UUID('a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890') + ac_data = 'eyJhbGciOiJFUzI1NiJ9.base64data' + ac_info = AdvancedCommerceInfo(messageIdentifier=message_id, advancedCommerceData=ac_data) + response_body = RealtimeResponseBody(advancedCommerceInfo=ac_info) + + c = _get_cattrs_converter(RealtimeResponseBody) + json_dict = c.unstructure(response_body) + + self.assertIn('advancedCommerceInfo', json_dict) + self.assertEqual('a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890', json_dict['advancedCommerceInfo']['messageIdentifier']) + self.assertEqual(ac_data, json_dict['advancedCommerceInfo']['advancedCommerceData']) + self.assertNotIn('message', json_dict) + self.assertNotIn('alternateProduct', json_dict) + self.assertNotIn('promotionalOffer', json_dict) + + deserialized = c.structure(json_dict, RealtimeResponseBody) + + self.assertIsNone(deserialized.message) + self.assertIsNone(deserialized.alternateProduct) + self.assertIsNone(deserialized.promotionalOffer) + self.assertIsNotNone(deserialized.advancedCommerceInfo) + self.assertEqual(message_id, deserialized.advancedCommerceInfo.messageIdentifier) + self.assertEqual(ac_data, deserialized.advancedCommerceInfo.advancedCommerceData) diff --git a/tests/test_x509_verifiction.py b/tests/test_x509_verifiction.py index 461e612f..048d58c7 100644 --- a/tests/test_x509_verifiction.py +++ b/tests/test_x509_verifiction.py @@ -1,6 +1,8 @@ # Copyright (c) 2023 Apple Inc. Licensed under MIT License. import unittest +from unittest import mock +from unittest.mock import MagicMock, patch from appstoreserverlibrary.signed_data_verifier import _ChainVerifier, VerificationException, VerificationStatus from base64 import b64decode, b64encode @@ -17,9 +19,10 @@ REAL_APPLE_ROOT_BASE64_ENCODED = "MIICQzCCAcmgAwIBAgIILcX8iNLFS5UwCgYIKoZIzj0EAwMwZzEbMBkGA1UEAwwSQXBwbGUgUm9vdCBDQSAtIEczMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwHhcNMTQwNDMwMTgxOTA2WhcNMzkwNDMwMTgxOTA2WjBnMRswGQYDVQQDDBJBcHBsZSBSb290IENBIC0gRzMxJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzB2MBAGByqGSM49AgEGBSuBBAAiA2IABJjpLz1AcqTtkyJygRMc3RCV8cWjTnHcFBbZDuWmBSp3ZHtfTjjTuxxEtX/1H7YyYl3J6YRbTzBPEVoA/VhYDKX1DyxNB0cTddqXl5dvMVztK517IDvYuVTZXpmkOlEKMaNCMEAwHQYDVR0OBBYEFLuw3qFYM4iapIqZ3r6966/ayySrMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2gAMGUCMQCD6cHEFl4aXTQY2e3v9GwOAEZLuN+yRhHFD/3meoyhpmvOwgPUnPWTxnS4at+qIxUCMG1mihDK1A3UT82NQz60imOlM27jbdoXt2QfyFMm+YhidDkLF1vLUagM6BgD56KyKA==" REAL_APPLE_INTERMEDIATE_BASE64_ENCODED = "MIIDFjCCApygAwIBAgIUIsGhRwp0c2nvU4YSycafPTjzbNcwCgYIKoZIzj0EAwMwZzEbMBkGA1UEAwwSQXBwbGUgUm9vdCBDQSAtIEczMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwHhcNMjEwMzE3MjAzNzEwWhcNMzYwMzE5MDAwMDAwWjB1MUQwQgYDVQQDDDtBcHBsZSBXb3JsZHdpZGUgRGV2ZWxvcGVyIFJlbGF0aW9ucyBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTELMAkGA1UECwwCRzYxEzARBgNVBAoMCkFwcGxlIEluYy4xCzAJBgNVBAYTAlVTMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEbsQKC94PrlWmZXnXgtxzdVJL8T0SGYngDRGpngn3N6PT8JMEb7FDi4bBmPhCnZ3/sq6PF/cGcKXWsL5vOteRhyJ45x3ASP7cOB+aao90fcpxSv/EZFbniAbNgZGhIhpIo4H6MIH3MBIGA1UdEwEB/wQIMAYBAf8CAQAwHwYDVR0jBBgwFoAUu7DeoVgziJqkipnevr3rr9rLJKswRgYIKwYBBQUHAQEEOjA4MDYGCCsGAQUFBzABhipodHRwOi8vb2NzcC5hcHBsZS5jb20vb2NzcDAzLWFwcGxlcm9vdGNhZzMwNwYDVR0fBDAwLjAsoCqgKIYmaHR0cDovL2NybC5hcHBsZS5jb20vYXBwbGVyb290Y2FnMy5jcmwwHQYDVR0OBBYEFD8vlCNR01DJmig97bB85c+lkGKZMA4GA1UdDwEB/wQEAwIBBjAQBgoqhkiG92NkBgIBBAIFADAKBggqhkjOPQQDAwNoADBlAjBAXhSq5IyKogMCPtw490BaB677CaEGJXufQB/EqZGd6CSjiCtOnuMTbXVXmxxcxfkCMQDTSPxarZXvNrkxU3TkUMI33yzvFVVRT4wxWJC994OsdcZ4+RGNsYDyR5gmdr0nDGg=" -REAL_APPLE_SIGNING_CERTIFICATE_BASE64_ENCODED = "MIIEMDCCA7agAwIBAgIQaPoPldvpSoEH0lBrjDPv9jAKBggqhkjOPQQDAzB1MUQwQgYDVQQDDDtBcHBsZSBXb3JsZHdpZGUgRGV2ZWxvcGVyIFJlbGF0aW9ucyBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTELMAkGA1UECwwCRzYxEzARBgNVBAoMCkFwcGxlIEluYy4xCzAJBgNVBAYTAlVTMB4XDTIxMDgyNTAyNTAzNFoXDTIzMDkyNDAyNTAzM1owgZIxQDA+BgNVBAMMN1Byb2QgRUNDIE1hYyBBcHAgU3RvcmUgYW5kIGlUdW5lcyBTdG9yZSBSZWNlaXB0IFNpZ25pbmcxLDAqBgNVBAsMI0FwcGxlIFdvcmxkd2lkZSBEZXZlbG9wZXIgUmVsYXRpb25zMRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABOoTcaPcpeipNL9eQ06tCu7pUcwdCXdN8vGqaUjd58Z8tLxiUC0dBeA+euMYggh1/5iAk+FMxUFmA2a1r4aCZ8SjggIIMIICBDAMBgNVHRMBAf8EAjAAMB8GA1UdIwQYMBaAFD8vlCNR01DJmig97bB85c+lkGKZMHAGCCsGAQUFBwEBBGQwYjAtBggrBgEFBQcwAoYhaHR0cDovL2NlcnRzLmFwcGxlLmNvbS93d2RyZzYuZGVyMDEGCCsGAQUFBzABhiVodHRwOi8vb2NzcC5hcHBsZS5jb20vb2NzcDAzLXd3ZHJnNjAyMIIBHgYDVR0gBIIBFTCCAREwggENBgoqhkiG92NkBQYBMIH+MIHDBggrBgEFBQcCAjCBtgyBs1JlbGlhbmNlIG9uIHRoaXMgY2VydGlmaWNhdGUgYnkgYW55IHBhcnR5IGFzc3VtZXMgYWNjZXB0YW5jZSBvZiB0aGUgdGhlbiBhcHBsaWNhYmxlIHN0YW5kYXJkIHRlcm1zIGFuZCBjb25kaXRpb25zIG9mIHVzZSwgY2VydGlmaWNhdGUgcG9saWN5IGFuZCBjZXJ0aWZpY2F0aW9uIHByYWN0aWNlIHN0YXRlbWVudHMuMDYGCCsGAQUFBwIBFipodHRwOi8vd3d3LmFwcGxlLmNvbS9jZXJ0aWZpY2F0ZWF1dGhvcml0eS8wHQYDVR0OBBYEFCOCmMBq//1L5imvVmqX1oCYeqrMMA4GA1UdDwEB/wQEAwIHgDAQBgoqhkiG92NkBgsBBAIFADAKBggqhkjOPQQDAwNoADBlAjEAl4JB9GJHixP2nuibyU1k3wri5psGIxPME05sFKq7hQuzvbeyBu82FozzxmbzpogoAjBLSFl0dZWIYl2ejPV+Di5fBnKPu8mymBQtoE/H2bES0qAs8bNueU3CBjjh1lwnDsI=" +REAL_APPLE_SIGNING_CERTIFICATE_BASE64_ENCODED = "MIIEMTCCA7agAwIBAgIQR8KHzdn554Z/UoradNx9tzAKBggqhkjOPQQDAzB1MUQwQgYDVQQDDDtBcHBsZSBXb3JsZHdpZGUgRGV2ZWxvcGVyIFJlbGF0aW9ucyBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTELMAkGA1UECwwCRzYxEzARBgNVBAoMCkFwcGxlIEluYy4xCzAJBgNVBAYTAlVTMB4XDTI1MDkxOTE5NDQ1MVoXDTI3MTAxMzE3NDcyM1owgZIxQDA+BgNVBAMMN1Byb2QgRUNDIE1hYyBBcHAgU3RvcmUgYW5kIGlUdW5lcyBTdG9yZSBSZWNlaXB0IFNpZ25pbmcxLDAqBgNVBAsMI0FwcGxlIFdvcmxkd2lkZSBEZXZlbG9wZXIgUmVsYXRpb25zMRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABNnVvhcv7iT+7Ex5tBMBgrQspHzIsXRi0Yxfek7lv8wEmj/bHiWtNwJqc2BoHzsQiEjP7KFIIKg4Y8y0/nynuAmjggIIMIICBDAMBgNVHRMBAf8EAjAAMB8GA1UdIwQYMBaAFD8vlCNR01DJmig97bB85c+lkGKZMHAGCCsGAQUFBwEBBGQwYjAtBggrBgEFBQcwAoYhaHR0cDovL2NlcnRzLmFwcGxlLmNvbS93d2RyZzYuZGVyMDEGCCsGAQUFBzABhiVodHRwOi8vb2NzcC5hcHBsZS5jb20vb2NzcDAzLXd3ZHJnNjAyMIIBHgYDVR0gBIIBFTCCAREwggENBgoqhkiG92NkBQYBMIH+MIHDBggrBgEFBQcCAjCBtgyBs1JlbGlhbmNlIG9uIHRoaXMgY2VydGlmaWNhdGUgYnkgYW55IHBhcnR5IGFzc3VtZXMgYWNjZXB0YW5jZSBvZiB0aGUgdGhlbiBhcHBsaWNhYmxlIHN0YW5kYXJkIHRlcm1zIGFuZCBjb25kaXRpb25zIG9mIHVzZSwgY2VydGlmaWNhdGUgcG9saWN5IGFuZCBjZXJ0aWZpY2F0aW9uIHByYWN0aWNlIHN0YXRlbWVudHMuMDYGCCsGAQUFBwIBFipodHRwOi8vd3d3LmFwcGxlLmNvbS9jZXJ0aWZpY2F0ZWF1dGhvcml0eS8wHQYDVR0OBBYEFIFioG4wMMVA1ku9zJmGNPAVn3eqMA4GA1UdDwEB/wQEAwIHgDAQBgoqhkiG92NkBgsBBAIFADAKBggqhkjOPQQDAwNpADBmAjEA+qXnREC7hXIWVLsLxznjRpIzPf7VHz9V/CTm8+LJlrQepnmcPvGLNcX6XPnlcgLAAjEA5IjNZKgg5pQ79knF4IbTXdKv8vutIDMXDmjPVT3dGvFtsGRwXOywR2kZCdSrfeot" -EFFECTIVE_DATE = 1681312846 +EFFECTIVE_DATE = 1761962975 +CLOCK_DATE = 41231 class X509Verification(unittest.TestCase): def test_valid_chain_without_ocsp(self): @@ -115,6 +118,82 @@ def test_apple_chain_is_valid_with_ocsp_and_strict(self): REAL_APPLE_ROOT_BASE64_ENCODED ], True, EFFECTIVE_DATE) + def test_ocsp_response_caching(self): + verifier = _ChainVerifier([b64decode(REAL_APPLE_ROOT_BASE64_ENCODED)]) + magic_mock = MagicMock(return_value=LEAF_CERT_BASE64_ENCODED) + verifier._verify_chain_without_caching = magic_mock + with patch('time.time', mock.MagicMock(return_value=CLOCK_DATE)): + verifier.verify_chain([ + REAL_APPLE_SIGNING_CERTIFICATE_BASE64_ENCODED, + REAL_APPLE_INTERMEDIATE_BASE64_ENCODED, + REAL_APPLE_ROOT_BASE64_ENCODED + ], True, EFFECTIVE_DATE) + self.assertEqual(1, magic_mock.call_count) + with patch('time.time', mock.MagicMock(return_value=CLOCK_DATE + 1)): # 1 second + verifier.verify_chain([ + REAL_APPLE_SIGNING_CERTIFICATE_BASE64_ENCODED, + REAL_APPLE_INTERMEDIATE_BASE64_ENCODED, + REAL_APPLE_ROOT_BASE64_ENCODED + ], True, EFFECTIVE_DATE) + self.assertEqual(1, magic_mock.call_count) + + def test_ocsp_response_caching_has_expiration(self): + verifier = _ChainVerifier([b64decode(REAL_APPLE_ROOT_BASE64_ENCODED)]) + magic_mock = MagicMock(return_value=LEAF_CERT_BASE64_ENCODED) + verifier._verify_chain_without_caching = magic_mock + with patch('time.time', mock.MagicMock(return_value=CLOCK_DATE)): + verifier.verify_chain([ + REAL_APPLE_SIGNING_CERTIFICATE_BASE64_ENCODED, + REAL_APPLE_INTERMEDIATE_BASE64_ENCODED, + REAL_APPLE_ROOT_BASE64_ENCODED + ], True, EFFECTIVE_DATE) + self.assertEqual(1, magic_mock.call_count) + with patch('time.time', mock.MagicMock(return_value=CLOCK_DATE + 900)): # 15 minutes + verifier.verify_chain([ + REAL_APPLE_SIGNING_CERTIFICATE_BASE64_ENCODED, + REAL_APPLE_INTERMEDIATE_BASE64_ENCODED, + REAL_APPLE_ROOT_BASE64_ENCODED + ], True, EFFECTIVE_DATE) + self.assertEqual(2, magic_mock.call_count) + + def test_ocsp_response_caching_with_different_chain(self): + verifier = _ChainVerifier([b64decode(REAL_APPLE_ROOT_BASE64_ENCODED)]) + magic_mock = MagicMock(return_value=LEAF_CERT_BASE64_ENCODED) + verifier._verify_chain_without_caching = magic_mock + with patch('time.time', mock.MagicMock(return_value=CLOCK_DATE)): + verifier.verify_chain([ + LEAF_CERT_BASE64_ENCODED, + INTERMEDIATE_CA_BASE64_ENCODED, + ROOT_CA_BASE64_ENCODED + ], True, EFFECTIVE_DATE) + self.assertEqual(1, magic_mock.call_count) + with patch('time.time', mock.MagicMock(return_value=CLOCK_DATE)): # Same + verifier.verify_chain([ + REAL_APPLE_SIGNING_CERTIFICATE_BASE64_ENCODED, + REAL_APPLE_INTERMEDIATE_BASE64_ENCODED, + REAL_APPLE_ROOT_BASE64_ENCODED + ], True, EFFECTIVE_DATE) + self.assertEqual(2, magic_mock.call_count) + + def test_ocsp_response_caching_with_slightly_different_chain(self): + verifier = _ChainVerifier([b64decode(REAL_APPLE_ROOT_BASE64_ENCODED)]) + magic_mock = MagicMock(return_value=LEAF_CERT_BASE64_ENCODED) + verifier._verify_chain_without_caching = magic_mock + with patch('time.time', mock.MagicMock(return_value=CLOCK_DATE)): + verifier.verify_chain([ + LEAF_CERT_BASE64_ENCODED, + INTERMEDIATE_CA_BASE64_ENCODED, + ROOT_CA_BASE64_ENCODED + ], True, EFFECTIVE_DATE) + self.assertEqual(1, magic_mock.call_count) + with patch('time.time', mock.MagicMock(return_value=CLOCK_DATE)): # Same + verifier.verify_chain([ + LEAF_CERT_BASE64_ENCODED, + INTERMEDIATE_CA_BASE64_ENCODED, + REAL_APPLE_ROOT_BASE64_ENCODED + ], True, EFFECTIVE_DATE) + self.assertEqual(2, magic_mock.call_count) + if __name__ == '__main__': unittest.main() diff --git a/tests/util.py b/tests/util.py index 8dd9373c..6dbeb21a 100644 --- a/tests/util.py +++ b/tests/util.py @@ -15,11 +15,11 @@ def create_signed_data_from_json(path: str) -> str: data = read_data_from_file(path) decoded_data = json.loads(data) - private_key = ec.generate_private_key(ES256).private_bytes(encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.TraditionalOpenSSL, encryption_algorithm=serialization.NoEncryption()).decode() + private_key = ec.generate_private_key(ec.SECP256R1()).private_bytes(encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.TraditionalOpenSSL, encryption_algorithm=serialization.NoEncryption()).decode() return jwt.encode(payload=decoded_data, key=private_key, algorithm='ES256') def decode_json_from_signed_date(data: str) -> Dict[str, Any]: - public_key = ec.generate_private_key(ES256).public_key().public_bytes(encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo).decode() + public_key = ec.generate_private_key(ec.SECP256R1()).public_key().public_bytes(encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo).decode() return decode_complete(jwt=data, key=public_key, algorithms=['ES256'], options={"verify_signature": False}) def read_data_from_file(path: str) -> str: @@ -32,10 +32,6 @@ def read_data_from_binary_file(path: str) -> str: with open(full_path, mode='rb') as test_file: return test_file.read() -class ES256(ec.EllipticCurve): - name="prime256v1" - key_size = 256 - def get_signed_data_verifier(env: Environment, bundle_id: str, app_apple_id: int = 1234) -> SignedDataVerifier: verifier = SignedDataVerifier([read_data_from_binary_file('tests/resources/certs/testCA.der')], False, env, bundle_id, app_apple_id) verifier._chain_verifier.enable_strict_checks = False # We don't have authority identifiers on test certs