From 7cd26f8f283087baa8ebcc6bb4683675c3274c21 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Mon, 27 Nov 2023 11:18:55 -0600 Subject: [PATCH 001/101] Release 0.3.0 --- CHANGELOG.md | 7 +++++++ README.md | 2 +- setup.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec087e95..646cf159 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.3.0 +- Add missing status field to the Data model [#33] +- Add error codes from App Store Server API v1.9 [#39] +- Add new fields from App Store Server API v1.10 [#46] +- Add support for reading unknown enum values [#45] +- Add support for Xcode and LocalTesting environments [#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) diff --git a/README.md b/README.md index 5b655a74..7b63d9a5 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ signed_data_verifier = SignedDataVerifier(root_certificates, enable_online_check 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) diff --git a/setup.py b/setup.py index 0d5d7b7f..ee4fce08 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ setup( name="app-store-server-library", - version="0.2.1", + version="0.3.0", description="The App Store Server Library", long_description=long_description, long_description_content_type="text/markdown", From 5b9bf26431b882af9dc2dae9a95ec08204b4a413 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Wed, 29 Nov 2023 10:43:09 -0600 Subject: [PATCH 002/101] Adjust triggers for snapshot and release builds --- .github/workflows/ci-release.yml | 2 ++ .github/workflows/ci-snapshot.yml | 9 ++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci-release.yml b/.github/workflows/ci-release.yml index 9ac6488d..67f2680b 100644 --- a/.github/workflows/ci-release.yml +++ b/.github/workflows/ci-release.yml @@ -4,6 +4,8 @@ on: 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 steps: diff --git a/.github/workflows/ci-snapshot.yml b/.github/workflows/ci-snapshot.yml index ef298287..653306b6 100644 --- a/.github/workflows/ci-snapshot.yml +++ b/.github/workflows/ci-snapshot.yml @@ -1,12 +1,11 @@ name: Snapshot Builder 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: From f5b620a2bf1ea04cf8749306265949765b639147 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Mon, 4 Dec 2023 10:19:58 -0600 Subject: [PATCH 003/101] Adding test cases for unexpected values --- ...istoryResponseWithMalformedAppAppleId.json | 11 ++++ ...storyResponseWithMalformedEnvironment.json | 11 ++++ tests/test_api_client.py | 65 +++++++++++++++++++ 3 files changed, 87 insertions(+) create mode 100644 tests/resources/models/transactionHistoryResponseWithMalformedAppAppleId.json create mode 100644 tests/resources/models/transactionHistoryResponseWithMalformedEnvironment.json 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/test_api_client.py b/tests/test_api_client.py index 876b6ec0..19f45497 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -377,6 +377,71 @@ def test_unknown_error(self): return self.assertFalse(True) + + def test_get_transaction_history_with_unknown_environment(self): + client = self.get_client_with_body_from_file('tests/resources/models/transactionHistoryResponseWithMalformedEnvironment.json', + 'GET', + 'https://api.storekit-sandbox.itunes.apple.com/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 = client.get_transaction_history('1234', 'revision_input', request) + + 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://api.storekit-sandbox.itunes.apple.com/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 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') From 9288e73c5187d0a21af084e1bc33d0e3c877f40e Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Mon, 11 Dec 2023 20:00:12 -0600 Subject: [PATCH 004/101] Add error_message to APIException --- appstoreserverlibrary/api_client.py | 343 +++++++++++++++++++++++++++- tests/test_api_client.py | 3 + 2 files changed, 341 insertions(+), 5 deletions(-) diff --git a/appstoreserverlibrary/api_client.py b/appstoreserverlibrary/api_client.py index c219a630..be604ebe 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -36,60 +36,391 @@ 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 + """ + 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 + """ + 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 + """ + 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,11 +428,13 @@ 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) @@ -156,11 +489,11 @@ def _make_request(self, path: str, method: str, queryParameters: Dict[str, Union raise APIException(response.status_code) try: response_body = response.json() - raise APIException(response.status_code, response_body['errorCode']) + raise APIException(response.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(response.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) diff --git a/tests/test_api_client.py b/tests/test_api_client.py index 19f45497..cb53d253 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -340,6 +340,7 @@ 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) @@ -357,6 +358,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) @@ -374,6 +376,7 @@ 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) From c63c36e6b73b35a1dd195dafa25fca57b3492632 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Mon, 18 Dec 2023 15:09:14 -0600 Subject: [PATCH 005/101] Release 1.0 --- CHANGELOG.md | 3 +++ README.md | 4 ---- appstoreserverlibrary/api_client.py | 2 +- setup.py | 2 +- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 646cf159..ad69d206 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 1.0.0 +- Add error message to APIException [#52] + ## 0.3.0 - Add missing status field to the Data model [#33] - Add error codes from App Store Server API v1.9 [#39] diff --git a/README.md b/README.md index 7b63d9a5..377dd352 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,6 @@ The Python server library for the [App Store Server API](https://developer.apple 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. - ## Installation #### Requirements diff --git a/appstoreserverlibrary/api_client.py b/appstoreserverlibrary/api_client.py index be604ebe..bddcf105 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -471,7 +471,7 @@ def _make_request(self, path: str, method: str, queryParameters: Dict[str, Union 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", + 'User-Agent': "app-store-server-library/python/1.0.0", 'Authorization': 'Bearer ' + self._generate_token(), 'Accept': 'application/json' } diff --git a/setup.py b/setup.py index ee4fce08..e17ac4b5 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ setup( name="app-store-server-library", - version="0.3.0", + version="1.0.0", description="The App Store Server Library", long_description=long_description, long_description_content_type="text/markdown", From 935c3d4d98b814e830a256d0f48b5ae9ea5d5b26 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Tue, 19 Dec 2023 11:30:23 -0600 Subject: [PATCH 006/101] Remove Beta from Table of Contents --- README.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 377dd352..09de2b0b 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,10 @@ 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). ## Table of Contents -1. [Beta](#-beta-) -2. [Installation](#installation) -3. [Documentation](#documentation) -4. [Usage](#usage) -5. [Support](#support) +1. [Installation](#installation) +2. [Documentation](#documentation) +3. [Usage](#usage) +4. [Support](#support) ## Installation From e45b85dd95feab2d83999c975932181f20e7f69f Mon Sep 17 00:00:00 2001 From: Go Takagi <15936908+shimastripe@users.noreply.github.com> Date: Mon, 22 Jan 2024 19:15:26 +0900 Subject: [PATCH 007/101] Tweak patch --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 09de2b0b..fd5c9724 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ The Python server library for the [App Store Server API](https://developer.apple - Python 3.7+ -### Gradle -```groovy +### pip +```sh pip install app-store-server-library ``` From 0d7e1a5dca3e7ff1a84eff512aefb354de02ebd9 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Wed, 21 Feb 2024 18:13:32 -0800 Subject: [PATCH 008/101] Resolves https://github.com/apple/app-store-server-library-python/issues/58, add appAppleId + Production ValueError --- README.md | 3 ++- appstoreserverlibrary/signed_data_verifier.py | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index fd5c9724..6f4c39d5 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,8 @@ 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.." diff --git a/appstoreserverlibrary/signed_data_verifier.py b/appstoreserverlibrary/signed_data_verifier.py index cf22e3c4..61e7ee25 100644 --- a/appstoreserverlibrary/signed_data_verifier.py +++ b/appstoreserverlibrary/signed_data_verifier.py @@ -41,6 +41,8 @@ def __init__( 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: """ From acd24ddf204285b6fb259d3a669aa609ad604a62 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Wed, 21 Feb 2024 18:27:43 -0800 Subject: [PATCH 009/101] Bump cryptography and pyOpenSSL maximum version --- requirements.txt | 4 ++-- tests/util.py | 8 ++------ 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/requirements.txt b/requirements.txt index 75650d0f..fd35c8a3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ attrs >= 21.3.0 PyJWT >= 2.6.0, < 3 requests >= 2.28.0, < 3 -cryptography >= 40.0.0, < 42 -pyOpenSSL >= 23.1.1, < 24 +cryptography >= 40.0.0, < 43 +pyOpenSSL >= 23.1.1, < 25 asn1==2.7.0 cattrs==23.1.2 \ No newline at end of file diff --git a/tests/util.py b/tests/util.py index 8dd9373c..f8cfd9a2 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 From ac11031bef532356c83064ba0e44d3851aa1ffd4 Mon Sep 17 00:00:00 2001 From: Kyle Rimkus Date: Fri, 8 Mar 2024 14:55:51 -0800 Subject: [PATCH 010/101] Bump cryptography and pyOpenSSL maximum version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index e17ac4b5..af12d01a 100644 --- a/setup.py +++ b/setup.py @@ -15,6 +15,6 @@ 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'], + install_requires=["attrs >= 21.3.0", 'PyJWT >= 2.6.0, < 3', 'requests >= 2.28.0, < 3', 'cryptography >= 40.0.0', 'pyOpenSSL >= 23.1.1, < 25', 'asn1==2.7.0', 'cattrs==23.1.2'], package_data={"appstoreserverlibrary": ["py.typed"]}, ) From cdc2e4c78874b2f397be667fc9e6fa34b509aa18 Mon Sep 17 00:00:00 2001 From: Kyle Rimkus Date: Thu, 14 Mar 2024 10:58:36 -0700 Subject: [PATCH 011/101] Set a max cryptography version in setup.py --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index af12d01a..b1f7c43f 100644 --- a/setup.py +++ b/setup.py @@ -15,6 +15,6 @@ 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', 'pyOpenSSL >= 23.1.1, < 25', 'asn1==2.7.0', 'cattrs==23.1.2'], + install_requires=["attrs >= 21.3.0", 'PyJWT >= 2.6.0, < 3', 'requests >= 2.28.0, < 3', 'cryptography >= 40.0.0, < 43', 'pyOpenSSL >= 23.1.1, < 25', 'asn1==2.7.0', 'cattrs==23.1.2'], package_data={"appstoreserverlibrary": ["py.typed"]}, ) From 09ab944df93d59f21e267270b8ab7029930f6532 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Wed, 13 Mar 2024 10:09:11 -0700 Subject: [PATCH 012/101] Updating for App Store Server Notifications v2.10 https://developer.apple.com/documentation/appstoreservernotifications?changes=latest_minor --- .../models/ExternalPurchaseToken.py | 43 ++++++++++++ .../models/NotificationTypeV2.py | 1 + .../models/ResponseBodyV2DecodedPayload.py | 15 ++++- appstoreserverlibrary/models/Subtype.py | 1 + appstoreserverlibrary/signed_data_verifier.py | 14 +++- ...gnedExternalPurchaseTokenNotification.json | 13 ++++ ...ernalPurchaseTokenSandboxNotification.json | 13 ++++ tests/test_decoded_payloads.py | 65 ++++++++++++++++++- 8 files changed, 158 insertions(+), 7 deletions(-) create mode 100644 appstoreserverlibrary/models/ExternalPurchaseToken.py create mode 100644 tests/resources/models/signedExternalPurchaseTokenNotification.json create mode 100644 tests/resources/models/signedExternalPurchaseTokenSandboxNotification.json 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/NotificationTypeV2.py b/appstoreserverlibrary/models/NotificationTypeV2.py index e667a057..02402fd5 100644 --- a/appstoreserverlibrary/models/NotificationTypeV2.py +++ b/appstoreserverlibrary/models/NotificationTypeV2.py @@ -27,3 +27,4 @@ class NotificationTypeV2(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): TEST = "TEST" RENEWAL_EXTENSION = "RENEWAL_EXTENSION" REFUND_REVERSED = "REFUND_REVERSED" + EXTERNAL_PURCHASE_TOKEN = "EXTERNAL_PURCHASE_TOKEN" \ No newline at end of file diff --git a/appstoreserverlibrary/models/ResponseBodyV2DecodedPayload.py b/appstoreserverlibrary/models/ResponseBodyV2DecodedPayload.py index f9400c71..0409011d 100644 --- a/appstoreserverlibrary/models/ResponseBodyV2DecodedPayload.py +++ b/appstoreserverlibrary/models/ResponseBodyV2DecodedPayload.py @@ -3,8 +3,9 @@ from attr import define import attr -from .Data import Data +from .Data import Data +from .ExternalPurchaseToken import ExternalPurchaseToken from .LibraryUtility import AttrsRawValueAware from .NotificationTypeV2 import NotificationTypeV2 from .Subtype import Subtype @@ -53,7 +54,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 +76,15 @@ 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 + """ + + 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 """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/Subtype.py b/appstoreserverlibrary/models/Subtype.py index c816764c..5c06e3ad 100644 --- a/appstoreserverlibrary/models/Subtype.py +++ b/appstoreserverlibrary/models/Subtype.py @@ -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/signed_data_verifier.py b/appstoreserverlibrary/signed_data_verifier.py index 61e7ee25..78b23d9e 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 from base64 import b64decode from enum import IntEnum import time @@ -94,11 +94,21 @@ 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 + 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: """ 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/test_decoded_payloads.py b/tests/test_decoded_payloads.py index 237037df..35593c19 100644 --- a/tests/test_decoded_payloads.py +++ b/tests/test_decoded_payloads.py @@ -1,5 +1,6 @@ # Copyright (c) 2023 Apple Inc. Licensed under MIT License. +from typing import Optional import unittest from appstoreserverlibrary.models.AutoRenewStatus import AutoRenewStatus from appstoreserverlibrary.models.Environment import Environment @@ -15,7 +16,7 @@ from appstoreserverlibrary.models.TransactionReason import TransactionReason from appstoreserverlibrary.models.Type import Type -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): @@ -122,6 +123,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) @@ -148,6 +150,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 +159,62 @@ 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) \ No newline at end of file From 56c7ec2cbc15fe83d51846118aa8f14ad1173e9e Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Fri, 15 Mar 2024 17:17:49 -0700 Subject: [PATCH 013/101] Release v1.1.0 --- CHANGELOG.md | 25 +++++++++++++++---------- appstoreserverlibrary/api_client.py | 2 +- setup.py | 2 +- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad69d206..36999baa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,20 +1,25 @@ # Changelog +## 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 [#52] +- 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 [#33] -- Add error codes from App Store Server API v1.9 [#39] -- Add new fields from App Store Server API v1.10 [#46] -- Add support for reading unknown enum values [#45] -- Add support for Xcode and LocalTesting environments [#44] +- 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/appstoreserverlibrary/api_client.py b/appstoreserverlibrary/api_client.py index bddcf105..f278c9be 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -471,7 +471,7 @@ def _make_request(self, path: str, method: str, queryParameters: Dict[str, Union 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/1.0.0", + 'User-Agent': "app-store-server-library/python/1.1.0", 'Authorization': 'Bearer ' + self._generate_token(), 'Accept': 'application/json' } diff --git a/setup.py b/setup.py index b1f7c43f..2bd99fff 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ setup( name="app-store-server-library", - version="1.0.0", + version="1.1.0", description="The App Store Server Library", long_description=long_description, long_description_content_type="text/markdown", From 87c7b5da902784dab2c90b1968adc27ab33887f0 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Tue, 26 Mar 2024 22:33:07 -0700 Subject: [PATCH 014/101] Incorporating changes for App Store Server API v1.10.1 https://developer.apple.com/documentation/appstoreserverapi?changes=latest_minor --- appstoreserverlibrary/models/JWSTransactionDecodedPayload.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appstoreserverlibrary/models/JWSTransactionDecodedPayload.py b/appstoreserverlibrary/models/JWSTransactionDecodedPayload.py index 4b68f4cd..c919ab37 100644 --- a/appstoreserverlibrary/models/JWSTransactionDecodedPayload.py +++ b/appstoreserverlibrary/models/JWSTransactionDecodedPayload.py @@ -222,7 +222,7 @@ 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 """ From 16b8e232c9d9dde529080499c2b1e86f508599f5 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Thu, 28 Mar 2024 08:52:26 -0700 Subject: [PATCH 015/101] Update README to better document setup procedure --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 6f4c39d5..8d38afd2 100644 --- a/README.md +++ b/README.md @@ -23,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 From 55a8bb12b9124ce56c233614a60f2b2f52eb0119 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Thu, 28 Mar 2024 13:27:08 -0700 Subject: [PATCH 016/101] Update method documentation for SignedDataVerifier to link to Apple documentation --- appstoreserverlibrary/signed_data_verifier.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/appstoreserverlibrary/signed_data_verifier.py b/appstoreserverlibrary/signed_data_verifier.py index 78b23d9e..d76e7109 100644 --- a/appstoreserverlibrary/signed_data_verifier.py +++ b/appstoreserverlibrary/signed_data_verifier.py @@ -47,6 +47,7 @@ def __init__( 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 @@ -61,6 +62,7 @@ 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 :return: The decoded transaction info after verification @@ -76,6 +78,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 @@ -113,6 +116,7 @@ def _verify_notification(self, bundle_id: Optional[str], app_apple_id: Optional[ 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 From f1fe7fe55e69c263d3b51ccf5d6116df520c6cdd Mon Sep 17 00:00:00 2001 From: Callum Watkins Date: Sun, 7 Apr 2024 14:59:40 +0100 Subject: [PATCH 017/101] Mark optional params as Optional --- appstoreserverlibrary/api_client.py | 6 +++--- appstoreserverlibrary/signed_data_verifier.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/appstoreserverlibrary/api_client.py b/appstoreserverlibrary/api_client.py index f278c9be..5509bedb 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -521,7 +521,7 @@ def extend_subscription_renewal_date(self, original_transaction_id: str, extend_ """ return self._make_request("/inApps/v1/subscriptions/extend/" + original_transaction_id, "PUT", {}, extend_renewal_date_request, ExtendRenewalDateResponse) - def get_all_subscription_statuses(self, transaction_id: str, status: List[Status] = None) -> StatusResponse: + def get_all_subscription_statuses(self, 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 @@ -537,7 +537,7 @@ def get_all_subscription_statuses(self, transaction_id: str, status: List[Status return self._make_request("/inApps/v1/subscriptions/" + transaction_id, "GET", queryParameters, None, StatusResponse) - def get_refund_history(self, transaction_id: str, revision: str) -> RefundHistoryResponse: + def get_refund_history(self, 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 @@ -577,7 +577,7 @@ def get_test_notification_status(self, test_notification_token: str) -> CheckTes """ return self._make_request("/inApps/v1/notifications/test/" + test_notification_token, "GET", {}, None, CheckTestNotificationResponse) - 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 diff --git a/appstoreserverlibrary/signed_data_verifier.py b/appstoreserverlibrary/signed_data_verifier.py index d76e7109..a2b61ab6 100644 --- a/appstoreserverlibrary/signed_data_verifier.py +++ b/appstoreserverlibrary/signed_data_verifier.py @@ -34,7 +34,7 @@ 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 From 4babc8e0eff6d2d7dddff3164fe2ec1dcafde624 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Thu, 4 Apr 2024 09:25:10 -0700 Subject: [PATCH 018/101] Block creation of an AppStoreServerAPIClient with environment XCODE Use placeholder URL when LOCAL_TESTING environment option is provided --- appstoreserverlibrary/api_client.py | 4 ++ appstoreserverlibrary/models/Environment.py | 2 +- tests/test_api_client.py | 48 +++++++++++++-------- 3 files changed, 35 insertions(+), 19 deletions(-) diff --git a/appstoreserverlibrary/api_client.py b/appstoreserverlibrary/api_client.py index f278c9be..4ef76549 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -443,8 +443,12 @@ def __init__(self, http_status_code: int, raw_api_error: Optional[int] = None, e class AppStoreServerAPIClient: 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" + elif environment == Environment.LOCAL_TESTING: + self._base_url = "https://local-testing-base-url" else: self._base_url = "https://api.storekit-sandbox.itunes.apple.com" self._signing_key = serialization.load_pem_private_key(signing_key, password=None, backend=default_backend()) 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/tests/test_api_client.py b/tests/test_api_client.py index cb53d253..053d913c 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -46,7 +46,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 +65,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 +86,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 +134,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 +148,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 +164,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 +181,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}) @@ -222,7 +222,7 @@ def test_get_notification_history(self): def test_get_transaction_history(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'], @@ -259,7 +259,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 +271,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 +285,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 +297,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, @@ -330,7 +330,7 @@ def test_send_consumption_data(self): 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) @@ -344,11 +344,21 @@ def test_api_error(self): 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) @@ -366,7 +376,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) @@ -384,7 +394,7 @@ def test_unknown_error(self): def test_get_transaction_history_with_unknown_environment(self): client = self.get_client_with_body_from_file('tests/resources/models/transactionHistoryResponseWithMalformedEnvironment.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'], @@ -415,7 +425,7 @@ def test_get_transaction_history_with_unknown_environment(self): 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://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'], @@ -445,9 +455,11 @@ def test_get_transaction_history_with_malformed_app_apple_id(self): self.assertFalse(True) + 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): - signing_key = read_data_from_binary_file('tests/resources/certs/testSigningKey.p8') + 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]): self.assertEqual(expected_method, method) From 7fb2fc64feb43fa7a0646632525f4fb4a6ab8e52 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Tue, 9 Apr 2024 23:12:09 -0500 Subject: [PATCH 019/101] Add license information to setup.py Resolves #74 --- setup.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/setup.py b/setup.py index 2bd99fff..fce05df5 100644 --- a/setup.py +++ b/setup.py @@ -17,4 +17,6 @@ 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, < 43', 'pyOpenSSL >= 23.1.1, < 25', 'asn1==2.7.0', 'cattrs==23.1.2'], package_data={"appstoreserverlibrary": ["py.typed"]}, + license="MIT", + classifiers=["License :: OSI Approved :: MIT License"], ) From 9c07641464f4388d990be0ab0d4d37b2831b223d Mon Sep 17 00:00:00 2001 From: yinpeng Date: Thu, 11 Apr 2024 00:04:55 +0800 Subject: [PATCH 020/101] fix README word error --- README.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 8d38afd2..e32ca04e 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,8 @@ Download and store the root certificates found in the Apple Root Certificates se ```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" @@ -87,7 +86,7 @@ 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" @@ -125,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" From f902098693dcf0fa4299236a9df9ce86d6af4adf Mon Sep 17 00:00:00 2001 From: Sunny Dubey Date: Fri, 12 Apr 2024 01:10:34 +0530 Subject: [PATCH 021/101] fix: field name in comment --- appstoreserverlibrary/signed_data_verifier.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appstoreserverlibrary/signed_data_verifier.py b/appstoreserverlibrary/signed_data_verifier.py index a2b61ab6..b3ecacbe 100644 --- a/appstoreserverlibrary/signed_data_verifier.py +++ b/appstoreserverlibrary/signed_data_verifier.py @@ -64,7 +64,7 @@ def verify_and_decode_signed_transaction(self, signed_transaction: str) -> JWSTr 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 signedTransactionInfo field :return: The decoded transaction info after verification :throws VerificationException: Thrown if the data could not be verified """ From 6cc062a88e8f410d0c920e1d9c691929fe2eb153 Mon Sep 17 00:00:00 2001 From: Sunny Dubey Date: Fri, 12 Apr 2024 11:15:33 +0530 Subject: [PATCH 022/101] change in field name to match other libraries --- appstoreserverlibrary/signed_data_verifier.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appstoreserverlibrary/signed_data_verifier.py b/appstoreserverlibrary/signed_data_verifier.py index b3ecacbe..56bda598 100644 --- a/appstoreserverlibrary/signed_data_verifier.py +++ b/appstoreserverlibrary/signed_data_verifier.py @@ -64,7 +64,7 @@ def verify_and_decode_signed_transaction(self, signed_transaction: str) -> JWSTr 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 signedTransactionInfo field + :param signed_transaction: The signedTransaction field :return: The decoded transaction info after verification :throws VerificationException: Thrown if the data could not be verified """ From ac9d211cabcfe8fd5a2b844d2a020d9038ad0f9f Mon Sep 17 00:00:00 2001 From: yinpeng Date: Sat, 13 Apr 2024 02:27:45 +0800 Subject: [PATCH 023/101] fix: word correction --- appstoreserverlibrary/models/JWSTransactionDecodedPayload.py | 4 ++-- appstoreserverlibrary/models/Subtype.py | 2 +- appstoreserverlibrary/models/TransactionHistoryRequest.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/appstoreserverlibrary/models/JWSTransactionDecodedPayload.py b/appstoreserverlibrary/models/JWSTransactionDecodedPayload.py index c919ab37..39659fd5 100644 --- a/appstoreserverlibrary/models/JWSTransactionDecodedPayload.py +++ b/appstoreserverlibrary/models/JWSTransactionDecodedPayload.py @@ -203,7 +203,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,7 +222,7 @@ class JWSTransactionDecodedPayload(AttrsRawValueAware): price: Optional[int] = attr.ib(default=None) """ - The price, in milliunits, of the in-app purchase or subscription offer that you configured in App Store Connect. + The price, in milli units, of the in-app purchase or subscription offer that you configured in App Store Connect. https://developer.apple.com/documentation/appstoreserverapi/price """ diff --git a/appstoreserverlibrary/models/Subtype.py b/appstoreserverlibrary/models/Subtype.py index 5c06e3ad..2c5577f3 100644 --- a/appstoreserverlibrary/models/Subtype.py +++ b/appstoreserverlibrary/models/Subtype.py @@ -6,7 +6,7 @@ class Subtype(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): """ - A notification subtype value that App Store Server Notifications 2 uses. + A notification subtype value that App Store Server Notifications V2 uses. https://developer.apple.com/documentation/appstoreserverapi/notificationsubtype """ diff --git a/appstoreserverlibrary/models/TransactionHistoryRequest.py b/appstoreserverlibrary/models/TransactionHistoryRequest.py index 15bc27af..69f3b572 100644 --- a/appstoreserverlibrary/models/TransactionHistoryRequest.py +++ b/appstoreserverlibrary/models/TransactionHistoryRequest.py @@ -66,5 +66,5 @@ class TransactionHistoryRequest: revoked: Optional[bool] = attr.ib(default=None) """ - An optional Boolean value that indicates whether the response includes only revoked transactions when the value is true, or contains only nonrevoked transactions when the value is false. By default, the request doesn't include this parameter. + An optional Boolean value that indicates whether the response includes only revoked transactions when the value is true, or contains only non-revoked transactions when the value is false. By default, the request doesn't include this parameter. """ From 12c15feb83904290b2483b4ae3c52e63b23d30c9 Mon Sep 17 00:00:00 2001 From: yinpeng Date: Sat, 13 Apr 2024 02:29:21 +0800 Subject: [PATCH 024/101] Use is instead of == to check if a variable is None --- appstoreserverlibrary/api_client.py | 32 ++++++++++++++--------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/appstoreserverlibrary/api_client.py b/appstoreserverlibrary/api_client.py index 21309bea..f4a4fa89 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -472,8 +472,8 @@ def _generate_token(self) -> str: 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 + c = _get_cattrs_converter(type(body)) if body is not None else None + json = c.unstructure(body) if body is not None else None headers = { 'User-Agent': "app-store-server-library/python/1.1.0", 'Authorization': 'Bearer ' + self._generate_token(), @@ -481,8 +481,8 @@ def _make_request(self, path: str, method: str, queryParameters: Dict[str, Union } response = self._execute_request(method, url, queryParameters, headers, json) - if response.status_code >= 200 and response.status_code < 300: - if destination_class == None: + if 200 <= response.status_code < 300: + if destination_class is None: return c = _get_cattrs_converter(destination_class) response_body = response.json() @@ -536,7 +536,7 @@ def get_all_subscription_statuses(self, transaction_id: str, status: Optional[Li :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) @@ -553,7 +553,7 @@ def get_refund_history(self, transaction_id: str, revision: Optional[str]) -> Re """ 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) @@ -592,7 +592,7 @@ def get_notification_history(self, pagination_token: Optional[str], notification :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) @@ -608,31 +608,31 @@ def get_transaction_history(self, transaction_id: str, revision: Optional[str], :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) From c027a6958511bd6d365788ce1f9e283f907b5850 Mon Sep 17 00:00:00 2001 From: yinpeng Date: Sat, 13 Apr 2024 12:19:03 +0800 Subject: [PATCH 025/101] Method parameter annotation modification --- appstoreserverlibrary/api_client.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/appstoreserverlibrary/api_client.py b/appstoreserverlibrary/api_client.py index f4a4fa89..04500517 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -547,7 +547,7 @@ def get_refund_history(self, transaction_id: str, revision: Optional[str]) -> Re 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 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 """ @@ -564,7 +564,7 @@ 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 """ @@ -603,7 +603,8 @@ def get_transaction_history(self, transaction_id: str, revision: Optional[str], 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 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. :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 """ From edd941e39858106b19ccc0e3b1422293bd22279e Mon Sep 17 00:00:00 2001 From: yinpeng Date: Sat, 13 Apr 2024 17:13:58 +0800 Subject: [PATCH 026/101] typo --- tests/test_decoded_payloads.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_decoded_payloads.py b/tests/test_decoded_payloads.py index 35593c19..e1dcfd81 100644 --- a/tests/test_decoded_payloads.py +++ b/tests/test_decoded_payloads.py @@ -107,7 +107,7 @@ def test_renewal_info_decoding(self): self.assertEqual(1698148800000, renewal_info.recentSubscriptionStartDate) self.assertEqual(1698148850000, renewal_info.renewalDate) - 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() From 664adefb20b99c23b1cbdbc212fb5d24ca24027f Mon Sep 17 00:00:00 2001 From: yinpeng Date: Sat, 13 Apr 2024 17:47:43 +0800 Subject: [PATCH 027/101] apple documentation link updates and additions --- appstoreserverlibrary/models/ExtendRenewalDateRequest.py | 4 ++-- appstoreserverlibrary/models/JWSTransactionDecodedPayload.py | 2 +- appstoreserverlibrary/models/NotificationHistoryResponse.py | 1 + appstoreserverlibrary/models/NotificationTypeV2.py | 2 +- appstoreserverlibrary/models/OrderLookupResponse.py | 2 ++ .../models/SubscriptionGroupIdentifierItem.py | 2 ++ appstoreserverlibrary/models/Subtype.py | 2 +- 7 files changed, 10 insertions(+), 5 deletions(-) diff --git a/appstoreserverlibrary/models/ExtendRenewalDateRequest.py b/appstoreserverlibrary/models/ExtendRenewalDateRequest.py index 933944e3..d23a5201 100644 --- a/appstoreserverlibrary/models/ExtendRenewalDateRequest.py +++ b/appstoreserverlibrary/models/ExtendRenewalDateRequest.py @@ -17,9 +17,9 @@ class ExtendRenewalDateRequest: extendByDays: Optional[int] = attr.ib(default=None) """ The number of days to extend the subscription renewal date. - + The number of days is a number from 1 to 90. + https://developer.apple.com/documentation/appstoreserverapi/extendbydays - maximum: 90 """ extendReasonCode: Optional[ExtendReasonCode] = attr.ib(default=None) diff --git a/appstoreserverlibrary/models/JWSTransactionDecodedPayload.py b/appstoreserverlibrary/models/JWSTransactionDecodedPayload.py index 39659fd5..9f58892d 100644 --- a/appstoreserverlibrary/models/JWSTransactionDecodedPayload.py +++ b/appstoreserverlibrary/models/JWSTransactionDecodedPayload.py @@ -222,7 +222,7 @@ class JWSTransactionDecodedPayload(AttrsRawValueAware): price: Optional[int] = attr.ib(default=None) """ - The price, in milli units, of the in-app purchase or subscription offer that you configured in App Store Connect. + 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 """ 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 02402fd5..6ae98913 100644 --- a/appstoreserverlibrary/models/NotificationTypeV2.py +++ b/appstoreserverlibrary/models/NotificationTypeV2.py @@ -8,7 +8,7 @@ class NotificationTypeV2(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): """ A notification type value that App Store Server Notifications V2 uses. - https://developer.apple.com/documentation/appstoreserverapi/notificationtype + https://developer.apple.com/documentation/appstoreservernotifications/notificationtype """ SUBSCRIBED = "SUBSCRIBED" DID_CHANGE_RENEWAL_PREF = "DID_CHANGE_RENEWAL_PREF" 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/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 2c5577f3..29a6a7fd 100644 --- a/appstoreserverlibrary/models/Subtype.py +++ b/appstoreserverlibrary/models/Subtype.py @@ -8,7 +8,7 @@ class Subtype(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): """ A notification subtype value that App Store Server Notifications V2 uses. - https://developer.apple.com/documentation/appstoreserverapi/notificationsubtype + https://developer.apple.com/documentation/appstoreservernotifications/subtype """ INITIAL_BUY = "INITIAL_BUY" RESUBSCRIBE = "RESUBSCRIBE" From c09b8ea8fbd0bc0071300f755d213aecc83b3a55 Mon Sep 17 00:00:00 2001 From: yinpeng Date: Mon, 15 Apr 2024 21:19:38 +0800 Subject: [PATCH 028/101] apple documentation link add --- appstoreserverlibrary/models/RefundHistoryResponse.py | 2 ++ appstoreserverlibrary/models/StatusResponse.py | 1 + 2 files changed, 3 insertions(+) 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/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 From 2c7fdba66f8c7f78a723f30e657839f04658f7f4 Mon Sep 17 00:00:00 2001 From: yinpeng Date: Sun, 28 Apr 2024 10:13:51 +0800 Subject: [PATCH 029/101] recovery code --- appstoreserverlibrary/models/ExtendRenewalDateRequest.py | 2 +- appstoreserverlibrary/models/TransactionHistoryRequest.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/appstoreserverlibrary/models/ExtendRenewalDateRequest.py b/appstoreserverlibrary/models/ExtendRenewalDateRequest.py index d23a5201..c59c952a 100644 --- a/appstoreserverlibrary/models/ExtendRenewalDateRequest.py +++ b/appstoreserverlibrary/models/ExtendRenewalDateRequest.py @@ -17,9 +17,9 @@ class ExtendRenewalDateRequest: extendByDays: Optional[int] = attr.ib(default=None) """ The number of days to extend the subscription renewal date. - The number of days is a number from 1 to 90. https://developer.apple.com/documentation/appstoreserverapi/extendbydays + maximum: 90 """ extendReasonCode: Optional[ExtendReasonCode] = attr.ib(default=None) diff --git a/appstoreserverlibrary/models/TransactionHistoryRequest.py b/appstoreserverlibrary/models/TransactionHistoryRequest.py index 69f3b572..15bc27af 100644 --- a/appstoreserverlibrary/models/TransactionHistoryRequest.py +++ b/appstoreserverlibrary/models/TransactionHistoryRequest.py @@ -66,5 +66,5 @@ class TransactionHistoryRequest: revoked: Optional[bool] = attr.ib(default=None) """ - An optional Boolean value that indicates whether the response includes only revoked transactions when the value is true, or contains only non-revoked transactions when the value is false. By default, the request doesn't include this parameter. + An optional Boolean value that indicates whether the response includes only revoked transactions when the value is true, or contains only nonrevoked transactions when the value is false. By default, the request doesn't include this parameter. """ From a1f5f624b7c7515109dca8d721954dff2c670e87 Mon Sep 17 00:00:00 2001 From: yinpeng Date: Tue, 30 Apr 2024 19:36:57 +0800 Subject: [PATCH 030/101] fix: typo --- NOTICE.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NOTICE.txt b/NOTICE.txt index f6567a2b..3e3bb75b 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 From 352b2d44229495f7243f68de6ab9e51a7dd1b2d5 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Fri, 3 May 2024 19:25:29 -0700 Subject: [PATCH 031/101] Add support for App Store Server API v1.11 and App Store Server Notifications v2.11 https://developer.apple.com/documentation/appstoreservernotifications/app_store_server_notifications_changelog https://developer.apple.com/documentation/appstoreserverapi/app_store_server_api_changelog --- appstoreserverlibrary/api_client.py | 9 ++++++ .../models/ConsumptionRequest.py | 13 ++++++++ .../models/ConsumptionRequestReason.py | 17 ++++++++++ appstoreserverlibrary/models/Data.py | 13 ++++++++ .../models/NotificationTypeV2.py | 2 +- .../models/RefundPreference.py | 16 ++++++++++ appstoreserverlibrary/models/Subtype.py | 2 +- .../signedConsumptionRequestNotification.json | 16 ++++++++++ tests/test_api_client.py | 7 ++-- tests/test_decoded_payloads.py | 32 +++++++++++++++++++ 10 files changed, 123 insertions(+), 4 deletions(-) create mode 100644 appstoreserverlibrary/models/ConsumptionRequestReason.py create mode 100644 appstoreserverlibrary/models/RefundPreference.py create mode 100644 tests/resources/models/signedConsumptionRequestNotification.json diff --git a/appstoreserverlibrary/api_client.py b/appstoreserverlibrary/api_client.py index 04500517..2064ef49 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -308,6 +308,15 @@ class APIError(IntEnum): 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 """ SUBSCRIPTION_EXTENSION_INELIGIBLE = 4030004 diff --git a/appstoreserverlibrary/models/ConsumptionRequest.py b/appstoreserverlibrary/models/ConsumptionRequest.py index c9797106..c4d0a940 100644 --- a/appstoreserverlibrary/models/ConsumptionRequest.py +++ b/appstoreserverlibrary/models/ConsumptionRequest.py @@ -12,6 +12,7 @@ from .LifetimeDollarsRefunded import LifetimeDollarsRefunded from .Platform import Platform from .PlayTime import PlayTime +from .RefundPreference import RefundPreference from .UserStatus import UserStatus @define @@ -137,4 +138,16 @@ class ConsumptionRequest(AttrsRawValueAware): rawUserStatus: Optional[int] = UserStatus.create_raw_attr('userStatus') """ See userStatus + """ + + refundPreference: Optional[RefundPreference] = RefundPreference.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] = RefundPreference.create_raw_attr('refundPreference') + """ + See refundPreference """ \ 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/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/NotificationTypeV2.py b/appstoreserverlibrary/models/NotificationTypeV2.py index 6ae98913..a1b46b18 100644 --- a/appstoreserverlibrary/models/NotificationTypeV2.py +++ b/appstoreserverlibrary/models/NotificationTypeV2.py @@ -6,7 +6,7 @@ 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/appstoreservernotifications/notificationtype """ diff --git a/appstoreserverlibrary/models/RefundPreference.py b/appstoreserverlibrary/models/RefundPreference.py new file mode 100644 index 00000000..1a890ca5 --- /dev/null +++ b/appstoreserverlibrary/models/RefundPreference.py @@ -0,0 +1,16 @@ +# Copyright (c) 2024 Apple Inc. Licensed under MIT License. + +from enum import IntEnum + +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class RefundPreference(IntEnum, metaclass=AppStoreServerLibraryEnumMeta): + """ + A value that indicates your preferred outcome for the refund request. + + https://developer.apple.com/documentation/appstoreserverapi/refundpreference + """ + UNDECLARED = 0 + PREFER_GRANT = 1 + PREFER_DECLINE = 2 + NO_PREFERENCE = 3 diff --git a/appstoreserverlibrary/models/Subtype.py b/appstoreserverlibrary/models/Subtype.py index 29a6a7fd..d1736833 100644 --- a/appstoreserverlibrary/models/Subtype.py +++ b/appstoreserverlibrary/models/Subtype.py @@ -6,7 +6,7 @@ class Subtype(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): """ - A notification subtype value that App Store Server Notifications V2 uses. + A string that provides details about select notification types in version 2. https://developer.apple.com/documentation/appstoreservernotifications/subtype """ 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/test_api_client.py b/tests/test_api_client.py index 053d913c..05ac3a06 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -27,6 +27,7 @@ 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.RevocationReason import RevocationReason from appstoreserverlibrary.models.SendAttemptItem import SendAttemptItem from appstoreserverlibrary.models.SendAttemptResult import SendAttemptResult @@ -309,7 +310,8 @@ def test_send_consumption_data(self): 'playTime': 5, 'lifetimeDollarsRefunded': 6, 'lifetimeDollarsPurchased': 7, - 'userStatus': 4}) + 'userStatus': 4, + 'refundPreference': 3}) consumptionRequest = ConsumptionRequest( customerConsented=True, @@ -322,7 +324,8 @@ def test_send_consumption_data(self): 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=RefundPreference.NO_PREFERENCE ) client.send_consumption_data('49571273', consumptionRequest) diff --git a/tests/test_decoded_payloads.py b/tests/test_decoded_payloads.py index e1dcfd81..b031c1a1 100644 --- a/tests/test_decoded_payloads.py +++ b/tests/test_decoded_payloads.py @@ -3,6 +3,7 @@ from typing import Optional import unittest from appstoreserverlibrary.models.AutoRenewStatus import AutoRenewStatus +from appstoreserverlibrary.models.ConsumptionRequestReason import ConsumptionRequestReason from appstoreserverlibrary.models.Environment import Environment from appstoreserverlibrary.models.ExpirationIntent import ExpirationIntent from appstoreserverlibrary.models.InAppOwnershipType import InAppOwnershipType @@ -133,6 +134,37 @@ def test_notification_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') From 8e0a551284d8f1c563d03cb05ea5f0138c3e82cd Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Mon, 6 May 2024 17:16:00 -0700 Subject: [PATCH 032/101] Release version 1.2.0 --- CHANGELOG.md | 4 ++++ appstoreserverlibrary/api_client.py | 2 +- setup.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36999baa..95321728 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 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-node/pull/132] +- 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] diff --git a/appstoreserverlibrary/api_client.py b/appstoreserverlibrary/api_client.py index 2064ef49..d1717c0b 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -484,7 +484,7 @@ def _make_request(self, path: str, method: str, queryParameters: Dict[str, Union c = _get_cattrs_converter(type(body)) if body is not None else None json = c.unstructure(body) if body is not None else None headers = { - 'User-Agent': "app-store-server-library/python/1.1.0", + 'User-Agent': "app-store-server-library/python/1.2.0", 'Authorization': 'Bearer ' + self._generate_token(), 'Accept': 'application/json' } diff --git a/setup.py b/setup.py index fce05df5..c4e24367 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ setup( name="app-store-server-library", - version="1.1.0", + version="1.2.0", description="The App Store Server Library", long_description=long_description, long_description_content_type="text/markdown", From 220d14616179984461625152b85a40ccd3ef3c5e Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Mon, 6 May 2024 21:44:56 -0700 Subject: [PATCH 033/101] Updating Changelog link to point to python for 1.2.0 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95321728..f2ae370d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # Changelog ## 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-node/pull/132] +- 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 From 8306f2ed0ed8b00fc1391c308f42331cc23259b2 Mon Sep 17 00:00:00 2001 From: devinwang Date: Mon, 13 May 2024 15:06:34 +1200 Subject: [PATCH 034/101] fix: update the offer type to the correct type --- appstoreserverlibrary/models/JWSTransactionDecodedPayload.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appstoreserverlibrary/models/JWSTransactionDecodedPayload.py b/appstoreserverlibrary/models/JWSTransactionDecodedPayload.py index 9f58892d..b990b6f3 100644 --- a/appstoreserverlibrary/models/JWSTransactionDecodedPayload.py +++ b/appstoreserverlibrary/models/JWSTransactionDecodedPayload.py @@ -156,7 +156,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. From b61f909ec16cd6a67a9d719eb258b793f5d2c076 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Mon, 13 May 2024 15:56:27 -0700 Subject: [PATCH 035/101] Release v1.2.1 --- CHANGELOG.md | 3 +++ appstoreserverlibrary/api_client.py | 2 +- setup.py | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2ae370d..367fd10e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 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 diff --git a/appstoreserverlibrary/api_client.py b/appstoreserverlibrary/api_client.py index d1717c0b..7be36974 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -484,7 +484,7 @@ def _make_request(self, path: str, method: str, queryParameters: Dict[str, Union c = _get_cattrs_converter(type(body)) if body is not None else None json = c.unstructure(body) if body is not None else None headers = { - 'User-Agent': "app-store-server-library/python/1.2.0", + 'User-Agent': "app-store-server-library/python/1.2.1", 'Authorization': 'Bearer ' + self._generate_token(), 'Accept': 'application/json' } diff --git a/setup.py b/setup.py index c4e24367..1bd71959 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ setup( name="app-store-server-library", - version="1.2.0", + version="1.2.1", description="The App Store Server Library", long_description=long_description, long_description_content_type="text/markdown", From ba8aa6e4ddcf075eb0984417a2b66416bc7b2efb Mon Sep 17 00:00:00 2001 From: s_kovalev Date: Mon, 20 May 2024 16:34:41 +0400 Subject: [PATCH 036/101] cache cattrs converter to prevent memory leak --- appstoreserverlibrary/models/LibraryUtility.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/appstoreserverlibrary/models/LibraryUtility.py b/appstoreserverlibrary/models/LibraryUtility.py index 31add6cf..efa20e0d 100644 --- a/appstoreserverlibrary/models/LibraryUtility.py +++ b/appstoreserverlibrary/models/LibraryUtility.py @@ -1,6 +1,7 @@ # 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 attr import Attribute, has, ib, fields @@ -52,7 +53,8 @@ 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) From 91ae9a6f593bb981b817cd3fbcd38b94c7a5bb23 Mon Sep 17 00:00:00 2001 From: WFT Date: Thu, 6 Jun 2024 15:21:35 -0700 Subject: [PATCH 037/101] Fix use of deprecated datetime.utcnow() function This function has been deprecated in Python 3.12, as seen in this warning: ``` /usr/local/lib/python3.12/site-packages/appstoreserverlibrary/api\_client.py:469: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC). future\_time = datetime.datetime.utcnow() + datetime.timedelta(minutes=5) ``` You can see that warning when running the tests in this repository with a sufficiently new Python (`python3 -m unittest`). Luckily the replacement has been available for a while! I've used datetime.timezone.utc instead of datetime.UTC because that alias was only introduced in 3.11. --- appstoreserverlibrary/api_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appstoreserverlibrary/api_client.py b/appstoreserverlibrary/api_client.py index 7be36974..e977e283 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -466,7 +466,7 @@ def __init__(self, signing_key: bytes, key_id: str, issuer_id: str, bundle_id: s 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, From c23ced3b570416b41537e1f8d40b4001bdd25fbc Mon Sep 17 00:00:00 2001 From: WFT Date: Thu, 6 Jun 2024 15:29:22 -0700 Subject: [PATCH 038/101] Fix deprecation cryptography deprecation warning in tests This use of the cryptography package generates a warning, seen below. ``` /Users/Will/dev/forks/app-store-server-library-python/tests/util.py:22: CryptographyDeprecationWarning: Curve argument must be an instance of an EllipticCurve class. Did you pass a class by mistake? This will be an exception in a future version of cryptography. public_key = ec.generate_private_key(ec.SECP256R1).public_key().public_bytes(encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo).decode() ``` I noticed this while preparing https://github.com/apple/app-store-server-library-python/pull/93 & thought I'd toss this up as well. It passes the tests on my local machine, now without this warning. --- tests/util.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/util.py b/tests/util.py index f8cfd9a2..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(ec.SECP256R1).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(ec.SECP256R1).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: From 910a67641ff934d27d9c1e0514f9a09d1916a481 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Mon, 10 Jun 2024 15:49:02 -0700 Subject: [PATCH 039/101] Add support for App Store Server API v1.12 and App Store Server Notifications v2.12 https://developer.apple.com/documentation/appstoreserverapi/app_store_server_api_changelog https://developer.apple.com/documentation/appstoreservernotifications/app_store_server_notifications_changelog --- appstoreserverlibrary/api_client.py | 15 ++++-- .../models/JWSRenewalInfoDecodedPayload.py | 27 +++++++++++ .../models/NotificationTypeV2.py | 3 +- tests/resources/models/signedRenewalInfo.json | 5 +- tests/test_api_client.py | 47 +++++++++++++++++-- tests/test_decoded_payloads.py | 4 ++ 6 files changed, 91 insertions(+), 10 deletions(-) diff --git a/appstoreserverlibrary/api_client.py b/appstoreserverlibrary/api_client.py index e977e283..ed54c4db 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -2,7 +2,7 @@ import calendar import datetime -from enum import IntEnum +from enum import IntEnum, Enum from typing import Any, Dict, List, Optional, Type, TypeVar, Union from attr import define import requests @@ -450,6 +450,14 @@ def __init__(self, http_status_code: int, raw_api_error: Optional[int] = None, e except ValueError: pass +class GetTransactionHistoryVersion(str, Enum): + V1 = "v1" + """ + .. deprecated:: 1.3.0 + """ + + V2 = "v2" + class AppStoreServerAPIClient: def __init__(self, signing_key: bytes, key_id: str, issuer_id: str, bundle_id: str, environment: Environment): if environment == Environment.XCODE: @@ -606,7 +614,7 @@ def get_notification_history(self, pagination_token: Optional[str], notification return self._make_request("/inApps/v1/notifications/history", "POST", queryParameters, notification_history_request, NotificationHistoryResponse) - def get_transaction_history(self, transaction_id: str, revision: Optional[str], transaction_history_request: TransactionHistoryRequest) -> HistoryResponse: + def get_transaction_history(self, 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 @@ -614,6 +622,7 @@ def get_transaction_history(self, transaction_id: str, revision: Optional[str], :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 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 """ @@ -645,7 +654,7 @@ def get_transaction_history(self, transaction_id: str, revision: Optional[str], 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/" + version + "/history/" + transaction_id, "GET", queryParameters, None, HistoryResponse) def get_transaction_info(self, transaction_id: str) -> TransactionInfoResponse: """ diff --git a/appstoreserverlibrary/models/JWSRenewalInfoDecodedPayload.py b/appstoreserverlibrary/models/JWSRenewalInfoDecodedPayload.py index 023892e6..ccc62831 100644 --- a/appstoreserverlibrary/models/JWSRenewalInfoDecodedPayload.py +++ b/appstoreserverlibrary/models/JWSRenewalInfoDecodedPayload.py @@ -10,6 +10,7 @@ from .LibraryUtility import AttrsRawValueAware from .OfferType import OfferType from .PriceIncreaseStatus import PriceIncreaseStatus +from .OfferDiscountType import OfferDiscountType @define class JWSRenewalInfoDecodedPayload(AttrsRawValueAware): @@ -140,4 +141,30 @@ 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 of the discount offer. + + https://developer.apple.com/documentation/appstoreserverapi/offerdiscounttype + """ + + rawOfferDiscountType: Optional[str] = OfferDiscountType.create_raw_attr('offerDiscountType') + """ + See offerDiscountType """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/NotificationTypeV2.py b/appstoreserverlibrary/models/NotificationTypeV2.py index a1b46b18..09a1d8b5 100644 --- a/appstoreserverlibrary/models/NotificationTypeV2.py +++ b/appstoreserverlibrary/models/NotificationTypeV2.py @@ -27,4 +27,5 @@ class NotificationTypeV2(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): TEST = "TEST" RENEWAL_EXTENSION = "RENEWAL_EXTENSION" REFUND_REVERSED = "REFUND_REVERSED" - EXTERNAL_PURCHASE_TOKEN = "EXTERNAL_PURCHASE_TOKEN" \ No newline at end of file + EXTERNAL_PURCHASE_TOKEN = "EXTERNAL_PURCHASE_TOKEN" + ONE_TIME_CHARGE = "ONE_TIME_CHARGE" \ No newline at end of file diff --git a/tests/resources/models/signedRenewalInfo.json b/tests/resources/models/signedRenewalInfo.json index 17c07a8e..3bc565b0 100644 --- a/tests/resources/models/signedRenewalInfo.json +++ b/tests/resources/models/signedRenewalInfo.json @@ -12,5 +12,8 @@ "signedDate": 1698148800000, "environment": "LocalTesting", "recentSubscriptionStartDate": 1698148800000, - "renewalDate": 1698148850000 + "renewalDate": 1698148850000, + "renewalPrice": 9990, + "currency": "USD", + "offerDiscountType": "PAY_AS_YOU_GO" } \ No newline at end of file diff --git a/tests/test_api_client.py b/tests/test_api_client.py index 05ac3a06..84cbab40 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -4,7 +4,7 @@ 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 @@ -220,7 +220,7 @@ 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://local-testing-base-url/inApps/v1/history/1234', @@ -246,7 +246,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) @@ -397,7 +434,7 @@ def test_unknown_error(self): 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/v1/history/1234', + 'https://local-testing-base-url/inApps/v2/history/1234', {'revision': ['revision_input'], 'startDate': ['123455'], 'endDate': ['123456'], @@ -420,7 +457,7 @@ def test_get_transaction_history_with_unknown_environment(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.V2) self.assertIsNone(history_response.environment) self.assertEqual("LocalTestingxxx", history_response.rawEnvironment) diff --git a/tests/test_decoded_payloads.py b/tests/test_decoded_payloads.py index b031c1a1..ab6a0ae0 100644 --- a/tests/test_decoded_payloads.py +++ b/tests/test_decoded_payloads.py @@ -107,6 +107,10 @@ 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) def test_notification_decoding(self): signed_notification = create_signed_data_from_json('tests/resources/models/signedNotification.json') From 04e3c8560b2d9a79e087f984257444c07a128139 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Wed, 12 Jun 2024 18:47:49 -0700 Subject: [PATCH 040/101] Release v1.3.0 --- CHANGELOG.md | 6 ++++++ appstoreserverlibrary/api_client.py | 2 +- setup.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 367fd10e..05f8ec88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 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 diff --git a/appstoreserverlibrary/api_client.py b/appstoreserverlibrary/api_client.py index ed54c4db..66335126 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -492,7 +492,7 @@ def _make_request(self, path: str, method: str, queryParameters: Dict[str, Union c = _get_cattrs_converter(type(body)) if body is not None else None json = c.unstructure(body) if body is not None else None headers = { - 'User-Agent': "app-store-server-library/python/1.2.1", + 'User-Agent': "app-store-server-library/python/1.3.0", 'Authorization': 'Bearer ' + self._generate_token(), 'Accept': 'application/json' } diff --git a/setup.py b/setup.py index 1bd71959..8fd528a5 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ setup( name="app-store-server-library", - version="1.2.1", + version="1.3.0", description="The App Store Server Library", long_description=long_description, long_description_content_type="text/markdown", From cde6e066b7a9a6ddc1a5564933d6c2752e8a00c1 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Fri, 14 Jun 2024 14:03:43 -0500 Subject: [PATCH 041/101] Update README with v2 endpoint version for Get Transaction History --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e32ca04e..d960b432 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ 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 @@ -109,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) From 191749c4ea2be222dc0e1db5ad37aaf479eb229e Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Tue, 9 Jul 2024 09:22:03 -0600 Subject: [PATCH 042/101] Remove upper limits for libraries that are unlikely to break on upgrade --- requirements.txt | 6 +++--- setup.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements.txt b/requirements.txt index fd35c8a3..b044e44c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ attrs >= 21.3.0 PyJWT >= 2.6.0, < 3 requests >= 2.28.0, < 3 -cryptography >= 40.0.0, < 43 -pyOpenSSL >= 23.1.1, < 25 +cryptography >= 40.0.0 +pyOpenSSL >= 23.1.1 asn1==2.7.0 -cattrs==23.1.2 \ No newline at end of file +cattrs >= 23.1.2 \ No newline at end of file diff --git a/setup.py b/setup.py index 8fd528a5..4215714c 100644 --- a/setup.py +++ b/setup.py @@ -15,7 +15,7 @@ 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, < 43', 'pyOpenSSL >= 23.1.1, < 25', 'asn1==2.7.0', 'cattrs==23.1.2'], + install_requires=["attrs >= 21.3.0", 'PyJWT >= 2.6.0, < 3', 'requests >= 2.28.0, < 3', 'cryptography >= 40.0.0', 'pyOpenSSL >= 23.1.1', 'asn1==2.7.0', 'cattrs >= 23.1.2'], package_data={"appstoreserverlibrary": ["py.typed"]}, license="MIT", classifiers=["License :: OSI Approved :: MIT License"], From c3c169e0ad224296571197851dd6885bdf9be0f7 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Tue, 9 Jul 2024 09:29:49 -0600 Subject: [PATCH 043/101] Add support for App Store Server API v1.13 and App Store Server Notifications v2.13 https://developer.apple.com/documentation/appstoreserverapi/app_store_server_api_changelog https://developer.apple.com/documentation/appstoreservernotifications/app_store_server_notifications_changelog --- .../models/JWSRenewalInfoDecodedPayload.py | 9 ++++++++- appstoreserverlibrary/models/OfferType.py | 1 + tests/resources/models/signedRenewalInfo.json | 8 ++++++-- tests/test_decoded_payloads.py | 1 + 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/appstoreserverlibrary/models/JWSRenewalInfoDecodedPayload.py b/appstoreserverlibrary/models/JWSRenewalInfoDecodedPayload.py index ccc62831..58c5d734 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 @@ -167,4 +167,11 @@ class JWSRenewalInfoDecodedPayload(AttrsRawValueAware): 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 """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/OfferType.py b/appstoreserverlibrary/models/OfferType.py index f23799c0..a66fd83e 100644 --- a/appstoreserverlibrary/models/OfferType.py +++ b/appstoreserverlibrary/models/OfferType.py @@ -13,3 +13,4 @@ class OfferType(IntEnum, metaclass=AppStoreServerLibraryEnumMeta): INTRODUCTORY_OFFER = 1 PROMOTIONAL_OFFER = 2 SUBSCRIPTION_OFFER_CODE = 3 + WIN_BACK_OFFER = 4 diff --git a/tests/resources/models/signedRenewalInfo.json b/tests/resources/models/signedRenewalInfo.json index 3bc565b0..30b90d27 100644 --- a/tests/resources/models/signedRenewalInfo.json +++ b/tests/resources/models/signedRenewalInfo.json @@ -15,5 +15,9 @@ "renewalDate": 1698148850000, "renewalPrice": 9990, "currency": "USD", - "offerDiscountType": "PAY_AS_YOU_GO" -} \ No newline at end of file + "offerDiscountType": "PAY_AS_YOU_GO", + "eligibleWinBackOfferIds": [ + "eligible1", + "eligible2" + ] +} diff --git a/tests/test_decoded_payloads.py b/tests/test_decoded_payloads.py index ab6a0ae0..aaea38ce 100644 --- a/tests/test_decoded_payloads.py +++ b/tests/test_decoded_payloads.py @@ -111,6 +111,7 @@ def test_renewal_info_decoding(self): 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) def test_notification_decoding(self): signed_notification = create_signed_data_from_json('tests/resources/models/signedNotification.json') From 3630c466a4bb329cde026c4d3027eaee54acaea7 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Tue, 16 Jul 2024 12:24:20 -0700 Subject: [PATCH 044/101] Release v1.4.0 of the Python library --- CHANGELOG.md | 4 ++++ appstoreserverlibrary/api_client.py | 2 +- setup.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05f8ec88..64ae104c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 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 diff --git a/appstoreserverlibrary/api_client.py b/appstoreserverlibrary/api_client.py index 66335126..a6ff1669 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -492,7 +492,7 @@ def _make_request(self, path: str, method: str, queryParameters: Dict[str, Union c = _get_cattrs_converter(type(body)) if body is not None else None json = c.unstructure(body) if body is not None else None headers = { - 'User-Agent': "app-store-server-library/python/1.3.0", + 'User-Agent': "app-store-server-library/python/1.4.0", 'Authorization': 'Bearer ' + self._generate_token(), 'Accept': 'application/json' } diff --git a/setup.py b/setup.py index 8fd528a5..6e7ec0b3 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ setup( name="app-store-server-library", - version="1.3.0", + version="1.4.0", description="The App Store Server Library", long_description=long_description, long_description_content_type="text/markdown", From 5947281b0e0cbff511eaf2e073abfc5ed4fc4091 Mon Sep 17 00:00:00 2001 From: willhnation Date: Thu, 15 Aug 2024 08:34:14 -0600 Subject: [PATCH 045/101] Add check for original transaction identifier --- appstoreserverlibrary/receipt_utility.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/appstoreserverlibrary/receipt_utility.py b/appstoreserverlibrary/receipt_utility.py index 504005d5..69f53d26 100644 --- a/appstoreserverlibrary/receipt_utility.py +++ b/appstoreserverlibrary/receipt_utility.py @@ -10,6 +10,7 @@ PKCS7_OID = "1.2.840.113549.1.7.2" IN_APP_ARRAY = 17 TRANSACTION_IDENTIFIER = 1703 +ORIGINAL_TRANSACTION_IDENTIFIER = 1705 class ReceiptUtility: def extract_transaction_id_from_app_receipt(self, app_receipt: str) -> Optional[str]: @@ -71,7 +72,7 @@ def extract_transaction_id_from_app_receipt(self, app_receipt: str) -> Optional[ if ( tag.typ == asn1.Types.Primitive and tag.nr == asn1.Numbers.Integer - and value == TRANSACTION_IDENTIFIER + and (value == TRANSACTION_IDENTIFIER or value == ORIGINAL_TRANSACTION_IDENTIFIER) ): inapp_decoder.read() tag, value = inapp_decoder.read() From e799cae5fea1ed9850d602da363ff1d97c6b8f19 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Mon, 19 Aug 2024 18:49:57 -0700 Subject: [PATCH 046/101] Adding Async client backed by httpx --- NOTICE.txt | 16 + README.md | 32 ++ appstoreserverlibrary/api_client.py | 254 ++++++++++++- requirements.txt | 3 +- setup.py | 3 + tests/test_api_client_async.py | 528 ++++++++++++++++++++++++++++ 6 files changed, 818 insertions(+), 18 deletions(-) create mode 100644 tests/test_api_client_async.py diff --git a/NOTICE.txt b/NOTICE.txt index 3e3bb75b..1a415041 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -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 d960b432..f84bc8f7 100644 --- a/README.md +++ b/README.md @@ -139,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 a6ff1669..01360bc3 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -3,7 +3,7 @@ import calendar import datetime from enum import IntEnum, Enum -from typing import Any, Dict, List, Optional, Type, TypeVar, Union +from typing import Any, Dict, List, MutableMapping, Optional, Type, TypeVar, Union from attr import define import requests @@ -458,7 +458,7 @@ class GetTransactionHistoryVersion(str, Enum): V2 = "v2" -class AppStoreServerAPIClient: +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") @@ -486,35 +486,52 @@ 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 is not None else None - json = c.unstructure(body) if body is not None else None - headers = { + + 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/1.4.0", 'Authorization': 'Bearer ' + self._generate_token(), 'Accept': 'application/json' } - - response = self._execute_request(method, url, queryParameters, headers, json) - if 200 <= response.status_code < 300: + + 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['errorMessage']) + response_body = json_supplier() + raise APIException(status_code, response_body['errorCode'], response_body['errorMessage']) except APIException as e: raise e except Exception as e: - raise APIException(response.status_code) from e + raise APIException(status_code) from e + + +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]) -> T: + url = self._get_full_url(path) + json = self._get_request_json(body) + headers = self._get_headers() + + response = self._execute_request(method, url, queryParameters, headers, json) + 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: Dict[str, Any]) -> requests.Response: return requests.request(method, url, params=params, headers=headers, json=json) @@ -697,3 +714,206 @@ def send_consumption_data(self, transaction_id: str, consumption_request: Consum :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) + + +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]) -> T: + url = self._get_full_url(path) + json = self._get_request_json(body) + headers = self._get_headers() + + response = await self._execute_request(method, url, queryParameters, headers, json) + 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: Dict[str, Any]): + return await self.http_client.request(method, url, params=params, headers=headers, json=json) + + 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) + + 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("/inApps/v1/subscriptions/extend/" + original_transaction_id, "PUT", {}, extend_renewal_date_request, ExtendRenewalDateResponse) + + async def get_all_subscription_statuses(self, 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 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("/inApps/v1/subscriptions/" + transaction_id, "GET", queryParameters, None, StatusResponse) + + async def get_refund_history(self, 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. + :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("/inApps/v2/refund/lookup/" + transaction_id, "GET", queryParameters, None, RefundHistoryResponse) + + 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("/inApps/v1/subscriptions/extend/mass/" + product_id + "/" + request_identifier, "GET", {}, None, MassExtendRenewalDateStatusResponse) + + 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("/inApps/v1/notifications/test/" + test_notification_token, "GET", {}, None, CheckTestNotificationResponse) + + 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) + + async def get_transaction_history(self, 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 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/" + transaction_id, "GET", queryParameters, None, HistoryResponse) + + 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("/inApps/v1/transactions/" + transaction_id, "GET", {}, None, TransactionInfoResponse) + + 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("/inApps/v1/lookup/" + order_id, "GET", {}, None, OrderLookupResponse) + 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) + + async def send_consumption_data(self, transaction_id: str, consumption_request: ConsumptionRequest): + """ + 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 + + :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 + """ + await self._make_request("/inApps/v1/transactions/consumption/" + transaction_id, "PUT", {}, consumption_request, None) diff --git a/requirements.txt b/requirements.txt index b044e44c..c66e9f94 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,5 @@ requests >= 2.28.0, < 3 cryptography >= 40.0.0 pyOpenSSL >= 23.1.1 asn1==2.7.0 -cattrs >= 23.1.2 \ No newline at end of file +cattrs >= 23.1.2 +httpx==0.27.0 diff --git a/setup.py b/setup.py index ec57fff6..e12bd15f 100644 --- a/setup.py +++ b/setup.py @@ -16,6 +16,9 @@ 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', 'pyOpenSSL >= 23.1.1', 'asn1==2.7.0', 'cattrs >= 23.1.2'], + extras_require={ + "async": ["httpx"], + }, package_data={"appstoreserverlibrary": ["py.typed"]}, license="MIT", classifiers=["License :: OSI Approved :: MIT License"], diff --git a/tests/test_api_client_async.py b/tests/test_api_client_async.py new file mode 100644 index 00000000..21eea0a0 --- /dev/null +++ b/tests/test_api_client_async.py @@ -0,0 +1,528 @@ +# 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.AutoRenewStatus import AutoRenewStatus +from appstoreserverlibrary.models.ConsumptionRequest import ConsumptionRequest +from appstoreserverlibrary.models.ConsumptionStatus import ConsumptionStatus +from appstoreserverlibrary.models.DeliveryStatus import DeliveryStatus +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.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.UserStatus import UserStatus + +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 = ConsumptionRequest( + customerConsented=True, + consumptionStatus=ConsumptionStatus.NOT_CONSUMED, + platform=Platform.NON_APPLE, + sampleContentProvided=False, + deliveryStatus=DeliveryStatus.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=RefundPreference.NO_PREFERENCE + ) + + await client.send_consumption_data('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) + + 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): + 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]): + 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 ')) + 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) + 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) + From e91f0b3c2f48d60bec4c5cc7214febad95883152 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Mon, 19 Aug 2024 18:56:37 -0700 Subject: [PATCH 047/101] Drop support for Python 3.7 as it is EOL --- .github/workflows/ci-prb.yml | 4 +--- README.md | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-prb.yml b/.github/workflows/ci-prb.yml index d371e9c8..7a29acce 100644 --- a/.github/workflows/ci-prb.yml +++ b/.github/workflows/ci-prb.yml @@ -10,7 +10,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - python: [ "3.7", "3.8", "3.9", "3.10", "3.11" ] + python: [ "3.8", "3.9", "3.10", "3.11", "3.12" ] os: [ ubuntu-latest ] steps: - name: Checkout Code @@ -19,8 +19,6 @@ jobs: uses: actions/setup-python@v4 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/README.md b/README.md index d960b432..3cf3f21a 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ The Python server library for the [App Store Server API](https://developer.apple #### Requirements -- Python 3.7+ +- Python 3.8+ ### pip ```sh From c1012b58048ae93209d7e03c386686000987599f Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Mon, 9 Sep 2024 09:25:14 -0500 Subject: [PATCH 048/101] Release 1.5.0 --- CHANGELOG.md | 5 +++++ appstoreserverlibrary/api_client.py | 2 +- setup.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64ae104c..05689697 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 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] diff --git a/appstoreserverlibrary/api_client.py b/appstoreserverlibrary/api_client.py index 01360bc3..9946fced 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -492,7 +492,7 @@ def _get_full_url(self, path) -> str: def _get_headers(self) -> Dict[str, str]: return { - 'User-Agent': "app-store-server-library/python/1.4.0", + 'User-Agent': "app-store-server-library/python/1.5.0", 'Authorization': 'Bearer ' + self._generate_token(), 'Accept': 'application/json' } diff --git a/setup.py b/setup.py index e12bd15f..b6890443 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ setup( name="app-store-server-library", - version="1.4.0", + version="1.5.0", description="The App Store Server Library", long_description=long_description, long_description_content_type="text/markdown", From c9644e88503d107b364197faa2afeaa0b850e586 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Tue, 18 Feb 2025 09:13:18 -0800 Subject: [PATCH 049/101] Add Python link so the first link in the repo points to itself This will allow Github dependabot to correctly resolve the repo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index bcb535c8..ae22dadb 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # 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) 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). ## Table of Contents 1. [Installation](#installation) From c1ed93aea81671ea8e0b53c1c83cc81d71aa9bd0 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Tue, 18 Feb 2025 10:57:27 -0800 Subject: [PATCH 050/101] Release version 1.6.0 --- CHANGELOG.md | 3 +++ appstoreserverlibrary/api_client.py | 2 +- setup.py | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05689697..21a0a276 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 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] diff --git a/appstoreserverlibrary/api_client.py b/appstoreserverlibrary/api_client.py index 9946fced..d416a3fc 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -492,7 +492,7 @@ def _get_full_url(self, path) -> str: def _get_headers(self) -> Dict[str, str]: return { - 'User-Agent': "app-store-server-library/python/1.5.0", + 'User-Agent': "app-store-server-library/python/1.6.0", 'Authorization': 'Bearer ' + self._generate_token(), 'Accept': 'application/json' } diff --git a/setup.py b/setup.py index b6890443..b3d66e4b 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ setup( name="app-store-server-library", - version="1.5.0", + version="1.6.0", description="The App Store Server Library", long_description=long_description, long_description_content_type="text/markdown", From db1a1c3250277f5c8164e086e961c5fe6c00eb65 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Wed, 19 Feb 2025 18:09:40 -0800 Subject: [PATCH 051/101] Add verified chain caching to improve performance --- appstoreserverlibrary/signed_data_verifier.py | 31 +++++++- tests/test_x509_verifiction.py | 79 +++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/appstoreserverlibrary/signed_data_verifier.py b/appstoreserverlibrary/signed_data_verifier.py index 56bda598..3e52b7ca 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, Optional +from typing import List, Optional, Dict from base64 import b64decode from enum import IntEnum import time @@ -155,11 +155,25 @@ 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 + 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: @@ -295,7 +309,22 @@ def check_ocsp_status(self, cert: crypto.X509, issuer: crypto.X509, root: crypto 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 diff --git a/tests/test_x509_verifiction.py b/tests/test_x509_verifiction.py index 461e612f..98d1af99 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 @@ -20,6 +22,7 @@ REAL_APPLE_SIGNING_CERTIFICATE_BASE64_ENCODED = "MIIEMDCCA7agAwIBAgIQaPoPldvpSoEH0lBrjDPv9jAKBggqhkjOPQQDAzB1MUQwQgYDVQQDDDtBcHBsZSBXb3JsZHdpZGUgRGV2ZWxvcGVyIFJlbGF0aW9ucyBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTELMAkGA1UECwwCRzYxEzARBgNVBAoMCkFwcGxlIEluYy4xCzAJBgNVBAYTAlVTMB4XDTIxMDgyNTAyNTAzNFoXDTIzMDkyNDAyNTAzM1owgZIxQDA+BgNVBAMMN1Byb2QgRUNDIE1hYyBBcHAgU3RvcmUgYW5kIGlUdW5lcyBTdG9yZSBSZWNlaXB0IFNpZ25pbmcxLDAqBgNVBAsMI0FwcGxlIFdvcmxkd2lkZSBEZXZlbG9wZXIgUmVsYXRpb25zMRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABOoTcaPcpeipNL9eQ06tCu7pUcwdCXdN8vGqaUjd58Z8tLxiUC0dBeA+euMYggh1/5iAk+FMxUFmA2a1r4aCZ8SjggIIMIICBDAMBgNVHRMBAf8EAjAAMB8GA1UdIwQYMBaAFD8vlCNR01DJmig97bB85c+lkGKZMHAGCCsGAQUFBwEBBGQwYjAtBggrBgEFBQcwAoYhaHR0cDovL2NlcnRzLmFwcGxlLmNvbS93d2RyZzYuZGVyMDEGCCsGAQUFBzABhiVodHRwOi8vb2NzcC5hcHBsZS5jb20vb2NzcDAzLXd3ZHJnNjAyMIIBHgYDVR0gBIIBFTCCAREwggENBgoqhkiG92NkBQYBMIH+MIHDBggrBgEFBQcCAjCBtgyBs1JlbGlhbmNlIG9uIHRoaXMgY2VydGlmaWNhdGUgYnkgYW55IHBhcnR5IGFzc3VtZXMgYWNjZXB0YW5jZSBvZiB0aGUgdGhlbiBhcHBsaWNhYmxlIHN0YW5kYXJkIHRlcm1zIGFuZCBjb25kaXRpb25zIG9mIHVzZSwgY2VydGlmaWNhdGUgcG9saWN5IGFuZCBjZXJ0aWZpY2F0aW9uIHByYWN0aWNlIHN0YXRlbWVudHMuMDYGCCsGAQUFBwIBFipodHRwOi8vd3d3LmFwcGxlLmNvbS9jZXJ0aWZpY2F0ZWF1dGhvcml0eS8wHQYDVR0OBBYEFCOCmMBq//1L5imvVmqX1oCYeqrMMA4GA1UdDwEB/wQEAwIHgDAQBgoqhkiG92NkBgsBBAIFADAKBggqhkjOPQQDAwNoADBlAjEAl4JB9GJHixP2nuibyU1k3wri5psGIxPME05sFKq7hQuzvbeyBu82FozzxmbzpogoAjBLSFl0dZWIYl2ejPV+Di5fBnKPu8mymBQtoE/H2bES0qAs8bNueU3CBjjh1lwnDsI=" EFFECTIVE_DATE = 1681312846 +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() From e1be8dd02b7f13a6478300bd4c5d9dd9de00ac92 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Thu, 20 Feb 2025 09:40:36 -0800 Subject: [PATCH 052/101] Release 1.7.0 --- CHANGELOG.md | 3 +++ appstoreserverlibrary/api_client.py | 2 +- setup.py | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21a0a276..00a98366 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 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] diff --git a/appstoreserverlibrary/api_client.py b/appstoreserverlibrary/api_client.py index d416a3fc..5c383de7 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -492,7 +492,7 @@ def _get_full_url(self, path) -> str: def _get_headers(self) -> Dict[str, str]: return { - 'User-Agent': "app-store-server-library/python/1.6.0", + 'User-Agent': "app-store-server-library/python/1.7.0", 'Authorization': 'Bearer ' + self._generate_token(), 'Accept': 'application/json' } diff --git a/setup.py b/setup.py index b3d66e4b..320cb763 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ setup( name="app-store-server-library", - version="1.6.0", + version="1.7.0", description="The App Store Server Library", long_description=long_description, long_description_content_type="text/markdown", From ec4966b994e5958d4b74512bc78f6133c4e05626 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Thu, 20 Feb 2025 09:46:44 -0800 Subject: [PATCH 053/101] Update docs to use deploy-pages v4 --- .github/workflows/ci-release-docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-release-docs.yml b/.github/workflows/ci-release-docs.yml index 2f9f9f13..e38d0161 100644 --- a/.github/workflows/ci-release-docs.yml +++ b/.github/workflows/ci-release-docs.yml @@ -38,4 +38,4 @@ jobs: steps: - name: Deploy id: deployment - uses: actions/deploy-pages@v2 + uses: actions/deploy-pages@v4 From e8a2ac33b35052d4c31a477f6085b7915af2848b Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Thu, 20 Feb 2025 09:50:52 -0800 Subject: [PATCH 054/101] Throw ValueError if environment not one of the supported values Python does not enforce typing, meaning invalid types are possible --- appstoreserverlibrary/api_client.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/appstoreserverlibrary/api_client.py b/appstoreserverlibrary/api_client.py index 5c383de7..890cdfef 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -466,8 +466,10 @@ def __init__(self, signing_key: bytes, key_id: str, issuer_id: str, bundle_id: s self._base_url = "https://api.storekit.itunes.apple.com" elif environment == Environment.LOCAL_TESTING: self._base_url = "https://local-testing-base-url" - else: + elif environment == Environment.SANDBOX: self._base_url = "https://api.storekit-sandbox.itunes.apple.com" + else: + 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 From f026d8feb74d31314b8c34b98448f34c8efaf796 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 27 Feb 2025 07:02:34 +0000 Subject: [PATCH 055/101] Bump actions/upload-pages-artifact from 2 to 3 Bumps [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) from 2 to 3. - [Release notes](https://github.com/actions/upload-pages-artifact/releases) - [Commits](https://github.com/actions/upload-pages-artifact/compare/v2...v3) --- updated-dependencies: - dependency-name: actions/upload-pages-artifact dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci-release-docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-release-docs.yml b/.github/workflows/ci-release-docs.yml index e38d0161..78e6398e 100644 --- a/.github/workflows/ci-release-docs.yml +++ b/.github/workflows/ci-release-docs.yml @@ -25,7 +25,7 @@ jobs: - name: Spinx build run: sphinx-build -b html _staging _build - name: Upload docs - uses: actions/upload-pages-artifact@v2 + uses: actions/upload-pages-artifact@v3 with: path: _build deploy: From f3be38ba1ecc6346aa27641fdeb457996784e455 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 27 Feb 2025 07:02:37 +0000 Subject: [PATCH 056/101] Bump actions/setup-python from 4 to 5 Bumps [actions/setup-python](https://github.com/actions/setup-python) from 4 to 5. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci-prb.yml | 2 +- .github/workflows/ci-release-docs.yml | 2 +- .github/workflows/ci-release.yml | 2 +- .github/workflows/ci-snapshot.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-prb.yml b/.github/workflows/ci-prb.yml index 7a29acce..64c29c2e 100644 --- a/.github/workflows/ci-prb.yml +++ b/.github/workflows/ci-prb.yml @@ -16,7 +16,7 @@ jobs: - name: Checkout Code uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} - name: Install dependencies diff --git a/.github/workflows/ci-release-docs.yml b/.github/workflows/ci-release-docs.yml index e38d0161..7dc92445 100644 --- a/.github/workflows/ci-release-docs.yml +++ b/.github/workflows/ci-release-docs.yml @@ -12,7 +12,7 @@ jobs: steps: - uses: actions/checkout@v3 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3.11" - name: Install dependencies diff --git a/.github/workflows/ci-release.yml b/.github/workflows/ci-release.yml index 67f2680b..b7ed61aa 100644 --- a/.github/workflows/ci-release.yml +++ b/.github/workflows/ci-release.yml @@ -11,7 +11,7 @@ jobs: steps: - uses: actions/checkout@v3 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3.11" - name: Install build diff --git a/.github/workflows/ci-snapshot.yml b/.github/workflows/ci-snapshot.yml index 653306b6..38bb7052 100644 --- a/.github/workflows/ci-snapshot.yml +++ b/.github/workflows/ci-snapshot.yml @@ -11,7 +11,7 @@ jobs: steps: - uses: actions/checkout@v3 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3.11" - name: Install build From 960c96ade1b580aedf3f88d56d8e55b13973ef4c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 27 Feb 2025 07:02:40 +0000 Subject: [PATCH 057/101] Bump actions/checkout from 3 to 4 Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v3...v4) --- updated-dependencies: - dependency-name: actions/checkout dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci-prb.yml | 2 +- .github/workflows/ci-release-docs.yml | 2 +- .github/workflows/ci-release.yml | 2 +- .github/workflows/ci-snapshot.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-prb.yml b/.github/workflows/ci-prb.yml index 7a29acce..72ca0b08 100644 --- a/.github/workflows/ci-prb.yml +++ b/.github/workflows/ci-prb.yml @@ -14,7 +14,7 @@ jobs: os: [ ubuntu-latest ] steps: - name: Checkout Code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python }} uses: actions/setup-python@v4 with: diff --git a/.github/workflows/ci-release-docs.yml b/.github/workflows/ci-release-docs.yml index e38d0161..8434e86f 100644 --- a/.github/workflows/ci-release-docs.yml +++ b/.github/workflows/ci-release-docs.yml @@ -10,7 +10,7 @@ jobs: name: Python Doc Builder runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v4 with: diff --git a/.github/workflows/ci-release.yml b/.github/workflows/ci-release.yml index 67f2680b..2acb49bc 100644 --- a/.github/workflows/ci-release.yml +++ b/.github/workflows/ci-release.yml @@ -9,7 +9,7 @@ jobs: name: Python Release Builder runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v4 with: diff --git a/.github/workflows/ci-snapshot.yml b/.github/workflows/ci-snapshot.yml index 653306b6..f228f488 100644 --- a/.github/workflows/ci-snapshot.yml +++ b/.github/workflows/ci-snapshot.yml @@ -9,7 +9,7 @@ jobs: name: Python Snapshot Builder runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v4 with: From d64b98120bd90aeae4eb94333464704234edf3d6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 27 Feb 2025 07:02:51 +0000 Subject: [PATCH 058/101] Bump httpx from 0.27.0 to 0.28.1 Bumps [httpx](https://github.com/encode/httpx) from 0.27.0 to 0.28.1. - [Release notes](https://github.com/encode/httpx/releases) - [Changelog](https://github.com/encode/httpx/blob/master/CHANGELOG.md) - [Commits](https://github.com/encode/httpx/compare/0.27.0...0.28.1) --- updated-dependencies: - dependency-name: httpx dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index c66e9f94..e6578919 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,4 @@ cryptography >= 40.0.0 pyOpenSSL >= 23.1.1 asn1==2.7.0 cattrs >= 23.1.2 -httpx==0.27.0 +httpx==0.28.1 From 4dc5817da8806da70d139947e43e5ee469ee3cfc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 27 Feb 2025 07:02:55 +0000 Subject: [PATCH 059/101] Bump sphinx from 7.2.2 to 8.2.1 Bumps [sphinx](https://github.com/sphinx-doc/sphinx) from 7.2.2 to 8.2.1. - [Release notes](https://github.com/sphinx-doc/sphinx/releases) - [Changelog](https://github.com/sphinx-doc/sphinx/blob/v8.2.1/CHANGES.rst) - [Commits](https://github.com/sphinx-doc/sphinx/compare/v7.2.2...v8.2.1) --- updated-dependencies: - dependency-name: sphinx dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- docs/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index faf03223..9d27e802 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1 +1 @@ -sphinx == 7.2.2 \ No newline at end of file +sphinx == 8.2.1 \ No newline at end of file From 4b9baadbcbb257939db187e1d23ccf07c8e2edd7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 27 Feb 2025 07:02:57 +0000 Subject: [PATCH 060/101] Bump asn1 from 2.7.0 to 2.8.0 Bumps [asn1](https://github.com/andrivet/python-asn1) from 2.7.0 to 2.8.0. - [Release notes](https://github.com/andrivet/python-asn1/releases) - [Changelog](https://github.com/andrivet/python-asn1/blob/master/CHANGELOG.rst) - [Commits](https://github.com/andrivet/python-asn1/compare/v2.7.0...v2.8.0) --- updated-dependencies: - dependency-name: asn1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index c66e9f94..748a039f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,6 @@ PyJWT >= 2.6.0, < 3 requests >= 2.28.0, < 3 cryptography >= 40.0.0 pyOpenSSL >= 23.1.1 -asn1==2.7.0 +asn1==2.8.0 cattrs >= 23.1.2 httpx==0.27.0 diff --git a/setup.py b/setup.py index 320cb763..03f14a08 100644 --- a/setup.py +++ b/setup.py @@ -15,7 +15,7 @@ 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', 'pyOpenSSL >= 23.1.1', 'asn1==2.7.0', 'cattrs >= 23.1.2'], + install_requires=["attrs >= 21.3.0", 'PyJWT >= 2.6.0, < 3', 'requests >= 2.28.0, < 3', 'cryptography >= 40.0.0', 'pyOpenSSL >= 23.1.1', 'asn1==2.8.0', 'cattrs >= 23.1.2'], extras_require={ "async": ["httpx"], }, From 7c8832089ce335639816dd4fe25e52c8f2491ab3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Mar 2025 02:53:50 +0000 Subject: [PATCH 061/101] Bump sphinx from 8.2.1 to 8.2.3 Bumps [sphinx](https://github.com/sphinx-doc/sphinx) from 8.2.1 to 8.2.3. - [Release notes](https://github.com/sphinx-doc/sphinx/releases) - [Changelog](https://github.com/sphinx-doc/sphinx/blob/master/CHANGES.rst) - [Commits](https://github.com/sphinx-doc/sphinx/compare/v8.2.1...v8.2.3) --- updated-dependencies: - dependency-name: sphinx dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- docs/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 9d27e802..81e7e12f 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1 +1 @@ -sphinx == 8.2.1 \ No newline at end of file +sphinx == 8.2.3 \ No newline at end of file From ce2e993bd05e19fae22e543947539ff8e6786979 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Fri, 7 Mar 2025 15:10:19 -0800 Subject: [PATCH 062/101] Update to support App Store Server API 1.15 and App Store Server Notifications 2.15 https://developer.apple.com/documentation/appstoreserverapi/app-store-server-api-changelog https://developer.apple.com/documentation/appstoreservernotifications/app-store-server-notifications-changelog --- appstoreserverlibrary/api_client.py | 7 + .../jws_signature_creator.py | 140 ++++++++++++++++++ .../models/AppTransaction.py | 20 +++ .../models/JWSRenewalInfoDecodedPayload.py | 21 +++ .../models/JWSTransactionDecodedPayload.py | 14 ++ .../models/PurchasePlatform.py | 16 ++ tests/resources/models/appTransaction.json | 4 +- tests/resources/models/signedRenewalInfo.json | 5 +- tests/resources/models/signedTransaction.json | 4 +- tests/test_decoded_payloads.py | 9 ++ tests/test_jws_signature_creator.py | 128 ++++++++++++++++ ...est_promotional_offer_signature_creator.py | 2 +- 12 files changed, 366 insertions(+), 4 deletions(-) create mode 100644 appstoreserverlibrary/jws_signature_creator.py create mode 100644 appstoreserverlibrary/models/PurchasePlatform.py create mode 100644 tests/test_jws_signature_creator.py diff --git a/appstoreserverlibrary/api_client.py b/appstoreserverlibrary/api_client.py index 890cdfef..1289186f 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -319,6 +319,13 @@ class APIError(IntEnum): 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 + """ + SUBSCRIPTION_EXTENSION_INELIGIBLE = 4030004 """ An error that indicates the subscription doesn't qualify for a renewal-date extension due to its subscription state. 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/AppTransaction.py b/appstoreserverlibrary/models/AppTransaction.py index 7093a100..6d2c98c3 100644 --- a/appstoreserverlibrary/models/AppTransaction.py +++ b/appstoreserverlibrary/models/AppTransaction.py @@ -7,6 +7,7 @@ from .LibraryUtility import AttrsRawValueAware from .Environment import Environment +from .PurchasePlatform import PurchasePlatform @define class AppTransaction(AttrsRawValueAware): @@ -96,4 +97,23 @@ class AppTransaction(AttrsRawValueAware): 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 + """ + + appTransactionId: Optional[str] = attr.ib(default=None) + """ + The unique identifier of the app download transaction. + + https://developer.apple.com/documentation/storekit/apptransaction/apptransactionid + """ + + originalPlatform: Optional[PurchasePlatform] = PurchasePlatform.create_main_attr('rawOriginalPlatform') + """ + The platform on which the customer originally purchased the app. + + https://developer.apple.com/documentation/storekit/apptransaction/originalplatform-4mogz + """ + + rawOriginalPlatform: Optional[str] = PurchasePlatform.create_raw_attr('originalPlatform') + """ + See originalPlatform """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/JWSRenewalInfoDecodedPayload.py b/appstoreserverlibrary/models/JWSRenewalInfoDecodedPayload.py index 58c5d734..8a2d9ca6 100644 --- a/appstoreserverlibrary/models/JWSRenewalInfoDecodedPayload.py +++ b/appstoreserverlibrary/models/JWSRenewalInfoDecodedPayload.py @@ -174,4 +174,25 @@ class JWSRenewalInfoDecodedPayload(AttrsRawValueAware): 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 """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/JWSTransactionDecodedPayload.py b/appstoreserverlibrary/models/JWSTransactionDecodedPayload.py index b990b6f3..38fd0a6f 100644 --- a/appstoreserverlibrary/models/JWSTransactionDecodedPayload.py +++ b/appstoreserverlibrary/models/JWSTransactionDecodedPayload.py @@ -237,4 +237,18 @@ class JWSTransactionDecodedPayload(AttrsRawValueAware): rawOfferDiscountType: Optional[str] = OfferDiscountType.create_raw_attr('offerDiscountType') """ See offerDiscountType + """ + + 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 """ \ No newline at end of file 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/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/signedRenewalInfo.json b/tests/resources/models/signedRenewalInfo.json index 30b90d27..d4bf60be 100644 --- a/tests/resources/models/signedRenewalInfo.json +++ b/tests/resources/models/signedRenewalInfo.json @@ -19,5 +19,8 @@ "eligibleWinBackOfferIds": [ "eligible1", "eligible2" - ] + ], + "appTransactionId": "71134", + "offerPeriod": "P1Y", + "appAccountToken": "7e3fb20b-4cdb-47cc-936d-99d65f608138" } diff --git a/tests/resources/models/signedTransaction.json b/tests/resources/models/signedTransaction.json index 0211857b..f81bac8c 100644 --- a/tests/resources/models/signedTransaction.json +++ b/tests/resources/models/signedTransaction.json @@ -24,5 +24,7 @@ "storefrontId":"143441", "price": 10990, "currency": "USD", - "offerDiscountType": "PAY_AS_YOU_GO" + "offerDiscountType": "PAY_AS_YOU_GO", + "appTransactionId": "71134", + "offerPeriod": "P1Y" } \ No newline at end of file diff --git a/tests/test_decoded_payloads.py b/tests/test_decoded_payloads.py index aaea38ce..44709c47 100644 --- a/tests/test_decoded_payloads.py +++ b/tests/test_decoded_payloads.py @@ -11,6 +11,7 @@ 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.RevocationReason import RevocationReason from appstoreserverlibrary.models.Status import Status from appstoreserverlibrary.models.Subtype import Subtype @@ -38,6 +39,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') @@ -79,6 +83,8 @@ 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) def test_renewal_info_decoding(self): @@ -112,6 +118,9 @@ def test_renewal_info_decoding(self): 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) def test_notification_decoding(self): signed_notification = create_signed_data_from_json('tests/resources/models/signedNotification.json') 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 From e8696effdefb16cdb8f7b01b193550df11b76bab Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Fri, 7 Mar 2025 17:53:06 -0800 Subject: [PATCH 063/101] Release v1.8.0 --- CHANGELOG.md | 3 +++ appstoreserverlibrary/api_client.py | 2 +- setup.py | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00a98366..1c2c02b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 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] diff --git a/appstoreserverlibrary/api_client.py b/appstoreserverlibrary/api_client.py index 1289186f..6f946199 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -501,7 +501,7 @@ def _get_full_url(self, path) -> str: def _get_headers(self) -> Dict[str, str]: return { - 'User-Agent': "app-store-server-library/python/1.7.0", + 'User-Agent': "app-store-server-library/python/1.8.0", 'Authorization': 'Bearer ' + self._generate_token(), 'Accept': 'application/json' } diff --git a/setup.py b/setup.py index 320cb763..1e3fb53e 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ setup( name="app-store-server-library", - version="1.7.0", + version="1.8.0", description="The App Store Server Library", long_description=long_description, long_description_content_type="text/markdown", From fd0ef04127f7ca0f3b106dd311a9adea5b393d3b Mon Sep 17 00:00:00 2001 From: krepe90 Date: Fri, 16 May 2025 00:09:45 +0900 Subject: [PATCH 064/101] Fix SyntaxWarning in regex pattern string In Python 3.12, invalid escape sequence (such as `\s`) in normal string emits SyntaxWarning. So I changed the regex pattern strings to use raw string literals (prefixing the string with `r`). See also: https://docs.python.org/3/whatsnew/3.12.html#other-language-changes --- appstoreserverlibrary/receipt_utility.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/appstoreserverlibrary/receipt_utility.py b/appstoreserverlibrary/receipt_utility.py index 69f53d26..7c66274b 100644 --- a/appstoreserverlibrary/receipt_utility.py +++ b/appstoreserverlibrary/receipt_utility.py @@ -92,10 +92,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 From 3c43c15c568d85b2613b571bd2cffab14c4a52f7 Mon Sep 17 00:00:00 2001 From: Riyaz Panjwani Date: Tue, 10 Jun 2025 18:31:55 -0700 Subject: [PATCH 065/101] Add support for Set App Account Token endpoint --- appstoreserverlibrary/api_client.py | 44 +++++++++++ .../models/UpdateAppAccountTokenRequest.py | 18 +++++ .../familyTransactionNotSupportedError.json | 4 + .../invalidAppAccountTokenUUIDError.json | 4 + ...transactionIdNotOriginalTransactionId.json | 4 + tests/test_api_client.py | 74 ++++++++++++++++++- tests/test_api_client_async.py | 69 +++++++++++++++++ 7 files changed, 213 insertions(+), 4 deletions(-) create mode 100644 appstoreserverlibrary/models/UpdateAppAccountTokenRequest.py create mode 100644 tests/resources/models/familyTransactionNotSupportedError.json create mode 100644 tests/resources/models/invalidAppAccountTokenUUIDError.json create mode 100644 tests/resources/models/transactionIdNotOriginalTransactionId.json diff --git a/appstoreserverlibrary/api_client.py b/appstoreserverlibrary/api_client.py index 6f946199..87369674 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -31,6 +31,7 @@ from .models.StatusResponse import StatusResponse from .models.TransactionHistoryRequest import TransactionHistoryRequest from .models.TransactionInfoResponse import TransactionInfoResponse +from .models.UpdateAppAccountTokenRequest import UpdateAppAccountTokenRequest T = TypeVar('T') @@ -326,6 +327,28 @@ class APIError(IntEnum): https://developer.apple.com/documentation/appstoreserverapi/apptransactionidnotsupportederror """ + 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 + """ + SUBSCRIPTION_EXTENSION_INELIGIBLE = 4030004 """ An error that indicates the subscription doesn't qualify for a renewal-date extension due to its subscription state. @@ -724,6 +747,16 @@ def send_consumption_data(self, transaction_id: str, consumption_request: Consum """ self._make_request("/inApps/v1/transactions/consumption/" + transaction_id, "PUT", {}, consumption_request, 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("/inApps/v1/transactions/" + original_transaction_id + "/appAccountToken", "PUT", {}, update_app_account_token_request, None) class AsyncAppStoreServerAPIClient(BaseAppStoreServerAPIClient): def __init__(self, signing_key: bytes, key_id: str, issuer_id: str, bundle_id: str, environment: Environment): @@ -926,3 +959,14 @@ async def send_consumption_data(self, transaction_id: str, consumption_request: :raises APIException: If a response was returned indicating the request could not be processed """ await self._make_request("/inApps/v1/transactions/consumption/" + transaction_id, "PUT", {}, consumption_request, 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("/inApps/v1/transactions/" + original_transaction_id + "/appAccountToken", "PUT", {}, update_app_account_token_request, None) 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/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/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/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_api_client.py b/tests/test_api_client.py index 84cbab40..a3151fe9 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -26,17 +26,14 @@ 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.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 tests.util import decode_json_from_signed_date, read_data_from_binary_file, read_data_from_file @@ -495,6 +492,75 @@ def test_get_transaction_history_with_malformed_app_apple_id(self): 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 get_signing_key(self): return read_data_from_binary_file('tests/resources/certs/testSigningKey.p8') diff --git a/tests/test_api_client_async.py b/tests/test_api_client_async.py index 21eea0a0..1114001f 100644 --- a/tests/test_api_client_async.py +++ b/tests/test_api_client_async.py @@ -38,6 +38,7 @@ 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 tests.util import decode_json_from_signed_date, read_data_from_binary_file, read_data_from_file @@ -496,6 +497,74 @@ async def test_get_transaction_history_with_malformed_app_apple_id(self): 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) + def get_signing_key(self): return read_data_from_binary_file('tests/resources/certs/testSigningKey.p8') From 82dd6d48200cc6da946da9ae4527fc1ca69db8e4 Mon Sep 17 00:00:00 2001 From: Riyaz Panjwani Date: Thu, 12 Jun 2025 15:34:53 -0700 Subject: [PATCH 066/101] Release v1.9.0 --- CHANGELOG.md | 4 ++++ appstoreserverlibrary/api_client.py | 2 +- setup.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c2c02b1..a01f2e11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 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] diff --git a/appstoreserverlibrary/api_client.py b/appstoreserverlibrary/api_client.py index 87369674..630ceb3e 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -524,7 +524,7 @@ def _get_full_url(self, path) -> str: def _get_headers(self) -> Dict[str, str]: return { - 'User-Agent': "app-store-server-library/python/1.8.0", + 'User-Agent': "app-store-server-library/python/1.9.0", 'Authorization': 'Bearer ' + self._generate_token(), 'Accept': 'application/json' } diff --git a/setup.py b/setup.py index 555da8ca..31ba801c 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ setup( name="app-store-server-library", - version="1.8.0", + version="1.9.0", description="The App Store Server Library", long_description=long_description, long_description_content_type="text/markdown", From fe7e84663b9c90dbe4df79bd4fd9ae7ab9dccc5d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Aug 2025 04:02:59 +0000 Subject: [PATCH 067/101] Bump actions/upload-pages-artifact from 3 to 4 Bumps [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) from 3 to 4. - [Release notes](https://github.com/actions/upload-pages-artifact/releases) - [Commits](https://github.com/actions/upload-pages-artifact/compare/v3...v4) --- updated-dependencies: - dependency-name: actions/upload-pages-artifact dependency-version: '4' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci-release-docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-release-docs.yml b/.github/workflows/ci-release-docs.yml index 2f80b951..6a40c9de 100644 --- a/.github/workflows/ci-release-docs.yml +++ b/.github/workflows/ci-release-docs.yml @@ -25,7 +25,7 @@ jobs: - name: Spinx build run: sphinx-build -b html _staging _build - name: Upload docs - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v4 with: path: _build deploy: From df44438657e3ba4a46acfaab88e48d7e72424121 Mon Sep 17 00:00:00 2001 From: Mike Drob Date: Wed, 27 Aug 2025 11:18:17 -0500 Subject: [PATCH 068/101] Use Trusted Publisher for releasing --- .github/workflows/ci-release.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-release.yml b/.github/workflows/ci-release.yml index cb3732c0..7d6f4e69 100644 --- a/.github/workflows/ci-release.yml +++ b/.github/workflows/ci-release.yml @@ -8,6 +8,9 @@ jobs: if: "!github.event.release.prerelease" name: Python Release Builder runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write steps: - uses: actions/checkout@v4 - name: Set up Python @@ -26,5 +29,3 @@ jobs: 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 From 95c6aa65c8f30038c47d165fe2f646f2a0d4f35f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 27 Aug 2025 16:22:50 +0000 Subject: [PATCH 069/101] Bump actions/checkout from 4 to 5 Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci-prb.yml | 2 +- .github/workflows/ci-release-docs.yml | 2 +- .github/workflows/ci-release.yml | 2 +- .github/workflows/ci-snapshot.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-prb.yml b/.github/workflows/ci-prb.yml index 4feb917f..8030888b 100644 --- a/.github/workflows/ci-prb.yml +++ b/.github/workflows/ci-prb.yml @@ -14,7 +14,7 @@ jobs: os: [ ubuntu-latest ] steps: - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Python ${{ matrix.python }} uses: actions/setup-python@v5 with: diff --git a/.github/workflows/ci-release-docs.yml b/.github/workflows/ci-release-docs.yml index 2f80b951..5ae13b0d 100644 --- a/.github/workflows/ci-release-docs.yml +++ b/.github/workflows/ci-release-docs.yml @@ -10,7 +10,7 @@ jobs: name: Python Doc Builder runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Python uses: actions/setup-python@v5 with: diff --git a/.github/workflows/ci-release.yml b/.github/workflows/ci-release.yml index 7d6f4e69..a9822f47 100644 --- a/.github/workflows/ci-release.yml +++ b/.github/workflows/ci-release.yml @@ -12,7 +12,7 @@ jobs: permissions: id-token: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Python uses: actions/setup-python@v5 with: diff --git a/.github/workflows/ci-snapshot.yml b/.github/workflows/ci-snapshot.yml index a12311e4..ff967e8f 100644 --- a/.github/workflows/ci-snapshot.yml +++ b/.github/workflows/ci-snapshot.yml @@ -9,7 +9,7 @@ jobs: name: Python Snapshot Builder runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Python uses: actions/setup-python@v5 with: From fc636bd715a10e2a2e5fd717b86a04fa87feb4c9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 5 Sep 2025 02:07:03 +0000 Subject: [PATCH 070/101] Bump actions/setup-python from 5 to 6 Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 6. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci-prb.yml | 2 +- .github/workflows/ci-release-docs.yml | 2 +- .github/workflows/ci-release.yml | 2 +- .github/workflows/ci-snapshot.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-prb.yml b/.github/workflows/ci-prb.yml index 4feb917f..896d90e5 100644 --- a/.github/workflows/ci-prb.yml +++ b/.github/workflows/ci-prb.yml @@ -16,7 +16,7 @@ jobs: - name: Checkout Code uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python }} - name: Install dependencies diff --git a/.github/workflows/ci-release-docs.yml b/.github/workflows/ci-release-docs.yml index 2f80b951..5f6785b2 100644 --- a/.github/workflows/ci-release-docs.yml +++ b/.github/workflows/ci-release-docs.yml @@ -12,7 +12,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.11" - name: Install dependencies diff --git a/.github/workflows/ci-release.yml b/.github/workflows/ci-release.yml index 7d6f4e69..ac2c7fef 100644 --- a/.github/workflows/ci-release.yml +++ b/.github/workflows/ci-release.yml @@ -14,7 +14,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.11" - name: Install build diff --git a/.github/workflows/ci-snapshot.yml b/.github/workflows/ci-snapshot.yml index a12311e4..7dd6cbe8 100644 --- a/.github/workflows/ci-snapshot.yml +++ b/.github/workflows/ci-snapshot.yml @@ -11,7 +11,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.11" - name: Install build From c30e0d59ee2d0977b20fb3190a44d9908701e21a Mon Sep 17 00:00:00 2001 From: Melissa Kilby Date: Fri, 17 Oct 2025 13:30:03 -0700 Subject: [PATCH 071/101] chore: restrict GitHub workflow permissions - future-proof Signed-off-by: Melissa Kilby --- .github/workflows/ci-prb.yml | 2 ++ .github/workflows/ci-release-docs.yml | 8 +++++--- .github/workflows/ci-release.yml | 2 ++ .github/workflows/ci-snapshot.yml | 2 ++ 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci-prb.yml b/.github/workflows/ci-prb.yml index 4feb917f..8ff50a1b 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 ] diff --git a/.github/workflows/ci-release-docs.yml b/.github/workflows/ci-release-docs.yml index 2f80b951..382c6c9c 100644 --- a/.github/workflows/ci-release-docs.yml +++ b/.github/workflows/ci-release-docs.yml @@ -1,10 +1,9 @@ name: Doc Builder +permissions: + contents: read on: release: types: [published] -permissions: - pages: write - id-token: write jobs: build: name: Python Doc Builder @@ -29,6 +28,9 @@ jobs: with: path: _build deploy: + permissions: + pages: write + id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} diff --git a/.github/workflows/ci-release.yml b/.github/workflows/ci-release.yml index 7d6f4e69..ee4efc45 100644 --- a/.github/workflows/ci-release.yml +++ b/.github/workflows/ci-release.yml @@ -1,4 +1,6 @@ name: Release Builder +permissions: + contents: read on: release: types: [published] diff --git a/.github/workflows/ci-snapshot.yml b/.github/workflows/ci-snapshot.yml index a12311e4..f801cbf4 100644 --- a/.github/workflows/ci-snapshot.yml +++ b/.github/workflows/ci-snapshot.yml @@ -1,4 +1,6 @@ name: Snapshot Builder +permissions: + contents: read on: release: types: [published] From fae5c2c4205cbdace586c7a72d930c89992cfae8 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Fri, 17 Oct 2025 20:19:05 -0700 Subject: [PATCH 072/101] Add support for the Retention Messaging API 1.0-1.1 https://developer.apple.com/documentation/retentionmessaging --- README.md | 2 +- appstoreserverlibrary/api_client.py | 366 ++++++++++++++++-- .../models/AlternateProduct.py | 28 ++ .../models/DecodedRealtimeRequestBody.py | 73 ++++ .../models/DefaultConfigurationRequest.py | 22 ++ .../models/GetImageListResponse.py | 23 ++ .../models/GetImageListResponseItem.py | 37 ++ .../models/GetMessageListResponse.py | 23 ++ .../models/GetMessageListResponseItem.py | 37 ++ appstoreserverlibrary/models/ImageState.py | 15 + .../models/LibraryUtility.py | 61 ++- appstoreserverlibrary/models/Message.py | 21 + appstoreserverlibrary/models/MessageState.py | 15 + .../models/PromotionalOffer.py | 38 ++ .../models/PromotionalOfferSignatureV1.py | 52 +++ .../models/RealtimeRequestBody.py | 21 + .../models/RealtimeResponseBody.py | 39 ++ .../models/UploadMessageImage.py | 28 ++ .../models/UploadMessageRequestBody.py | 37 ++ appstoreserverlibrary/signed_data_verifier.py | 18 + .../models/decodedRealtimeRequest.json | 9 + .../models/getImageListResponse.json | 8 + .../models/getMessageListResponse.json | 8 + tests/test_api_client.py | 115 +++++- tests/test_api_client_async.py | 114 +++++- tests/test_decoded_payloads.py | 18 +- tests/test_retention_messaging.py | 171 ++++++++ 27 files changed, 1330 insertions(+), 69 deletions(-) create mode 100644 appstoreserverlibrary/models/AlternateProduct.py create mode 100644 appstoreserverlibrary/models/DecodedRealtimeRequestBody.py create mode 100644 appstoreserverlibrary/models/DefaultConfigurationRequest.py create mode 100644 appstoreserverlibrary/models/GetImageListResponse.py create mode 100644 appstoreserverlibrary/models/GetImageListResponseItem.py create mode 100644 appstoreserverlibrary/models/GetMessageListResponse.py create mode 100644 appstoreserverlibrary/models/GetMessageListResponseItem.py create mode 100644 appstoreserverlibrary/models/ImageState.py create mode 100644 appstoreserverlibrary/models/Message.py create mode 100644 appstoreserverlibrary/models/MessageState.py create mode 100644 appstoreserverlibrary/models/PromotionalOffer.py create mode 100644 appstoreserverlibrary/models/PromotionalOfferSignatureV1.py create mode 100644 appstoreserverlibrary/models/RealtimeRequestBody.py create mode 100644 appstoreserverlibrary/models/RealtimeResponseBody.py create mode 100644 appstoreserverlibrary/models/UploadMessageImage.py create mode 100644 appstoreserverlibrary/models/UploadMessageRequestBody.py create mode 100644 tests/resources/models/decodedRealtimeRequest.json create mode 100644 tests/resources/models/getImageListResponse.json create mode 100644 tests/resources/models/getMessageListResponse.json create mode 100644 tests/test_retention_messaging.py diff --git a/README.md b/README.md index ae22dadb..24dfa085 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # Apple App Store Server Python Library -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) 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), and [Retention Messaging API](https://developer.apple.com/documentation/retentionmessaging). 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. [Installation](#installation) diff --git a/appstoreserverlibrary/api_client.py b/appstoreserverlibrary/api_client.py index 630ceb3e..8b661ddd 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -14,10 +14,12 @@ from appstoreserverlibrary.models.LibraryUtility import _get_cattrs_converter from .models.CheckTestNotificationResponse import CheckTestNotificationResponse from .models.ConsumptionRequest import ConsumptionRequest - +from .models.DefaultConfigurationRequest import DefaultConfigurationRequest 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.MassExtendRenewalDateRequest import MassExtendRenewalDateRequest from .models.MassExtendRenewalDateResponse import MassExtendRenewalDateResponse @@ -32,6 +34,8 @@ from .models.TransactionHistoryRequest import TransactionHistoryRequest from .models.TransactionInfoResponse import TransactionInfoResponse from .models.UpdateAppAccountTokenRequest import UpdateAppAccountTokenRequest +from .models.UploadMessageRequestBody import UploadMessageRequestBody +from uuid import UUID T = TypeVar('T') @@ -323,10 +327,45 @@ class APIError(IntEnum): 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. @@ -370,6 +409,41 @@ class APIError(IntEnum): 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 + """ + ACCOUNT_NOT_FOUND = 4040001 """ An error that indicates the App Store account wasn’t found. @@ -440,6 +514,34 @@ class APIError(IntEnum): 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 + """ + + 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. @@ -557,16 +659,22 @@ 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]) -> T: + 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) - json = self._get_request_json(body) headers = self._get_headers() - - response = self._execute_request(method, url, queryParameters, headers, json) + + 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: Dict[str, Any]) -> requests.Response: - return requests.request(method, url, params=params, headers=headers, json=json) + 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) def extend_renewal_date_for_all_active_subscribers(self, mass_extend_renewal_date_request: MassExtendRenewalDateRequest) -> MassExtendRenewalDateResponse: """ @@ -577,7 +685,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: """ @@ -589,7 +697,7 @@ 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("/inApps/v1/subscriptions/extend/" + original_transaction_id, "PUT", {}, extend_renewal_date_request, ExtendRenewalDateResponse, None) def get_all_subscription_statuses(self, transaction_id: str, status: Optional[List[Status]] = None) -> StatusResponse: """ @@ -605,7 +713,7 @@ def get_all_subscription_statuses(self, transaction_id: str, status: Optional[Li 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("/inApps/v1/subscriptions/" + transaction_id, "GET", queryParameters, None, StatusResponse, None) def get_refund_history(self, transaction_id: str, revision: Optional[str]) -> RefundHistoryResponse: """ @@ -622,7 +730,7 @@ def get_refund_history(self, transaction_id: str, revision: Optional[str]) -> Re 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("/inApps/v2/refund/lookup/" + transaction_id, "GET", queryParameters, None, RefundHistoryResponse, None) def get_status_of_subscription_renewal_date_extensions(self, request_identifier: str, product_id: str) -> MassExtendRenewalDateStatusResponse: """ @@ -634,7 +742,7 @@ def get_status_of_subscription_renewal_date_extensions(self, request_identifier: :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("/inApps/v1/subscriptions/extend/mass/" + product_id + "/" + request_identifier, "GET", {}, None, MassExtendRenewalDateStatusResponse, None) def get_test_notification_status(self, test_notification_token: str) -> CheckTestNotificationResponse: """ @@ -645,7 +753,7 @@ 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("/inApps/v1/notifications/test/" + test_notification_token, "GET", {}, None, CheckTestNotificationResponse, None) def get_notification_history(self, pagination_token: Optional[str], notification_history_request: NotificationHistoryRequest) -> NotificationHistoryResponse: """ @@ -661,7 +769,7 @@ def get_notification_history(self, pagination_token: Optional[str], notification 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, version: GetTransactionHistoryVersion = GetTransactionHistoryVersion.V1) -> HistoryResponse: """ @@ -703,7 +811,7 @@ def get_transaction_history(self, transaction_id: str, revision: Optional[str], if transaction_history_request.revoked is not None: queryParameters["revoked"] = [str(transaction_history_request.revoked)] - return self._make_request("/inApps/" + version + "/history/" + transaction_id, "GET", queryParameters, None, HistoryResponse) + return self._make_request("/inApps/" + version + "/history/" + transaction_id, "GET", queryParameters, None, HistoryResponse, None) def get_transaction_info(self, transaction_id: str) -> TransactionInfoResponse: """ @@ -714,7 +822,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("/inApps/v1/transactions/" + transaction_id, "GET", {}, None, TransactionInfoResponse, None) def look_up_order_id(self, order_id: str) -> OrderLookupResponse: """ @@ -725,7 +833,7 @@ 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("/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. @@ -734,7 +842,7 @@ 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): """ @@ -745,7 +853,7 @@ def send_consumption_data(self, transaction_id: str, consumption_request: Consum :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) + self._make_request("/inApps/v1/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): """ @@ -756,7 +864,92 @@ def set_app_account_token(self, original_transaction_id: str, update_app_account :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("/inApps/v1/transactions/" + original_transaction_id + "/appAccountToken", "PUT", {}, update_app_account_token_request, None) + self._make_request("/inApps/v1/transactions/" + original_transaction_id + "/appAccountToken", "PUT", {}, update_app_account_token_request, None, None) + + def upload_image(self, image_identifier: UUID, image: bytes): + """ + 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. + :raises APIException: If a response was returned indicating the request could not be processed + :see: https://developer.apple.com/documentation/retentionmessaging/upload-image + """ + self._make_request(f"/inApps/v1/messaging/image/{image_identifier}", "PUT", {}, 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) class AsyncAppStoreServerAPIClient(BaseAppStoreServerAPIClient): def __init__(self, signing_key: bytes, key_id: str, issuer_id: str, bundle_id: str, environment: Environment): @@ -770,16 +963,24 @@ def __init__(self, signing_key: bytes, key_id: str, issuer_id: str, bundle_id: s 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]) -> T: + 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) - json = self._get_request_json(body) headers = self._get_headers() - - response = await self._execute_request(method, url, queryParameters, headers, json) + + 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: Dict[str, Any]): - return await self.http_client.request(method, url, params=params, headers=headers, json=json) + 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) async def extend_renewal_date_for_all_active_subscribers(self, mass_extend_renewal_date_request: MassExtendRenewalDateRequest) -> MassExtendRenewalDateResponse: """ @@ -790,7 +991,7 @@ async def extend_renewal_date_for_all_active_subscribers(self, mass_extend_renew :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) + 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: """ @@ -802,7 +1003,7 @@ async def extend_subscription_renewal_date(self, original_transaction_id: str, e :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("/inApps/v1/subscriptions/extend/" + original_transaction_id, "PUT", {}, extend_renewal_date_request, ExtendRenewalDateResponse) + return await self._make_request("/inApps/v1/subscriptions/extend/" + original_transaction_id, "PUT", {}, extend_renewal_date_request, ExtendRenewalDateResponse, None) async def get_all_subscription_statuses(self, transaction_id: str, status: Optional[List[Status]] = None) -> StatusResponse: """ @@ -818,7 +1019,7 @@ async def get_all_subscription_statuses(self, transaction_id: str, status: Optio if status is not None: queryParameters["status"] = [s.value for s in status] - return await self._make_request("/inApps/v1/subscriptions/" + transaction_id, "GET", queryParameters, None, StatusResponse) + return await self._make_request("/inApps/v1/subscriptions/" + transaction_id, "GET", queryParameters, None, StatusResponse, None) async def get_refund_history(self, transaction_id: str, revision: Optional[str]) -> RefundHistoryResponse: """ @@ -835,7 +1036,7 @@ async def get_refund_history(self, transaction_id: str, revision: Optional[str]) if revision is not None: queryParameters["revision"] = [revision] - return await self._make_request("/inApps/v2/refund/lookup/" + transaction_id, "GET", queryParameters, None, RefundHistoryResponse) + return await self._make_request("/inApps/v2/refund/lookup/" + transaction_id, "GET", queryParameters, None, RefundHistoryResponse, None) async def get_status_of_subscription_renewal_date_extensions(self, request_identifier: str, product_id: str) -> MassExtendRenewalDateStatusResponse: """ @@ -847,7 +1048,7 @@ async def get_status_of_subscription_renewal_date_extensions(self, request_ident :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("/inApps/v1/subscriptions/extend/mass/" + product_id + "/" + request_identifier, "GET", {}, None, MassExtendRenewalDateStatusResponse) + return await self._make_request("/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: """ @@ -858,7 +1059,7 @@ async def get_test_notification_status(self, test_notification_token: str) -> Ch :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("/inApps/v1/notifications/test/" + test_notification_token, "GET", {}, None, CheckTestNotificationResponse) + return await self._make_request("/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: """ @@ -874,7 +1075,7 @@ async def get_notification_history(self, pagination_token: Optional[str], notifi 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) + return await self._make_request("/inApps/v1/notifications/history", "POST", queryParameters, notification_history_request, NotificationHistoryResponse, None) async def get_transaction_history(self, transaction_id: str, revision: Optional[str], transaction_history_request: TransactionHistoryRequest, version: GetTransactionHistoryVersion = GetTransactionHistoryVersion.V1) -> HistoryResponse: """ @@ -916,7 +1117,7 @@ async def get_transaction_history(self, transaction_id: str, revision: Optional[ if transaction_history_request.revoked is not None: queryParameters["revoked"] = [str(transaction_history_request.revoked)] - return await self._make_request("/inApps/" + version + "/history/" + transaction_id, "GET", queryParameters, None, HistoryResponse) + return await self._make_request("/inApps/" + version + "/history/" + transaction_id, "GET", queryParameters, None, HistoryResponse, None) async def get_transaction_info(self, transaction_id: str) -> TransactionInfoResponse: """ @@ -927,7 +1128,7 @@ async def get_transaction_info(self, transaction_id: str) -> TransactionInfoResp :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("/inApps/v1/transactions/" + transaction_id, "GET", {}, None, TransactionInfoResponse) + return await self._make_request("/inApps/v1/transactions/" + transaction_id, "GET", {}, None, TransactionInfoResponse, None) async def look_up_order_id(self, order_id: str) -> OrderLookupResponse: """ @@ -938,7 +1139,7 @@ async 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 await self._make_request("/inApps/v1/lookup/" + order_id, "GET", {}, None, OrderLookupResponse) + return await self._make_request("/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. @@ -947,7 +1148,7 @@ async 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 await self._make_request("/inApps/v1/notifications/test", "POST", {}, None, SendTestNotificationResponse) + return await self._make_request("/inApps/v1/notifications/test", "POST", {}, None, SendTestNotificationResponse, None) async def send_consumption_data(self, transaction_id: str, consumption_request: ConsumptionRequest): """ @@ -958,7 +1159,7 @@ async def send_consumption_data(self, transaction_id: str, consumption_request: :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("/inApps/v1/transactions/consumption/" + transaction_id, "PUT", {}, consumption_request, None) + await self._make_request("/inApps/v1/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): """ @@ -969,4 +1170,89 @@ async def set_app_account_token(self, original_transaction_id: str, update_app_a :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("/inApps/v1/transactions/" + original_transaction_id + "/appAccountToken", "PUT", {}, update_app_account_token_request, None) + await self._make_request("/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): + """ + 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. + :raises APIException: If a response was returned indicating the request could not be processed + :see: https://developer.apple.com/documentation/retentionmessaging/upload-image + """ + await self._make_request(f"/inApps/v1/messaging/image/{image_identifier}", "PUT", {}, 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) diff --git a/appstoreserverlibrary/models/AlternateProduct.py b/appstoreserverlibrary/models/AlternateProduct.py new file mode 100644 index 00000000..b8b4dd6f --- /dev/null +++ b/appstoreserverlibrary/models/AlternateProduct.py @@ -0,0 +1,28 @@ +# 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 AlternateProduct: + """ + 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 + """ 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..f7f3d6bc --- /dev/null +++ b/appstoreserverlibrary/models/DefaultConfigurationRequest.py @@ -0,0 +1,22 @@ +# 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. + + https://developer.apple.com/documentation/retentionmessaging/messageidentifier + """ 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..4f00945e --- /dev/null +++ b/appstoreserverlibrary/models/GetImageListResponseItem.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 .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 + """ \ 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/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/LibraryUtility.py b/appstoreserverlibrary/models/LibraryUtility.py index efa20e0d..7d395025 100644 --- a/appstoreserverlibrary/models/LibraryUtility.py +++ b/appstoreserverlibrary/models/LibraryUtility.py @@ -3,6 +3,7 @@ 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 @@ -22,21 +23,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'}) + else: + return ib(default=None, kw_only=True, on_setattr=value_set, metadata={metadata_key: field_name, metadata_type_key: 'raw'}) class AttrsRawValueAware: def __attrs_post_init__(self): @@ -57,15 +76,27 @@ def __attrs_post_init__(self): @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)) + + # 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:] + cattrs_overrides[raw_field] = override(rename=matching_name, omit_if_default=True) + 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 \ No newline at end of file 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/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/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..0ebc785d --- /dev/null +++ b/appstoreserverlibrary/models/RealtimeResponseBody.py @@ -0,0 +1,39 @@ +# 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 + +@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 + """ 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..7d9472e4 --- /dev/null +++ b/appstoreserverlibrary/models/UploadMessageRequestBody.py @@ -0,0 +1,37 @@ +# Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +from typing import Optional + +from attr import define +import attr + +from .UploadMessageImage import UploadMessageImage + +@define +class UploadMessageRequestBody: + """ + 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 + """ diff --git a/appstoreserverlibrary/signed_data_verifier.py b/appstoreserverlibrary/signed_data_verifier.py index 3e52b7ca..581fc7d1 100644 --- a/appstoreserverlibrary/signed_data_verifier.py +++ b/appstoreserverlibrary/signed_data_verifier.py @@ -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 @@ -131,6 +132,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}) 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/getImageListResponse.json b/tests/resources/models/getImageListResponse.json new file mode 100644 index 00000000..652d2e96 --- /dev/null +++ b/tests/resources/models/getImageListResponse.json @@ -0,0 +1,8 @@ +{ + "imageIdentifiers": [ + { + "imageIdentifier": "a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890", + "imageState": "APPROVED" + } + ] +} 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/test_api_client.py b/tests/test_api_client.py index a3151fe9..c7d8aa45 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -35,6 +35,12 @@ from appstoreserverlibrary.models.TransactionHistoryRequest import Order, ProductType, TransactionHistoryRequest from appstoreserverlibrary.models.UpdateAppAccountTokenRequest import UpdateAppAccountTokenRequest from appstoreserverlibrary.models.UserStatus import UserStatus +from appstoreserverlibrary.models.DefaultConfigurationRequest import DefaultConfigurationRequest +from appstoreserverlibrary.models.ImageState import ImageState +from appstoreserverlibrary.models.MessageState import MessageState +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 @@ -561,26 +567,123 @@ def test_transaction_id_not_original_transaction_id_error(self): 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) + + 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 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): + + 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 index 1114001f..f15b3aa7 100644 --- a/tests/test_api_client_async.py +++ b/tests/test_api_client_async.py @@ -40,6 +40,12 @@ 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.ImageState import ImageState +from appstoreserverlibrary.models.MessageState import MessageState +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 @@ -565,26 +571,122 @@ async def test_transaction_id_not_original_transaction_id_error(self): 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) + + 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') + 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): + + 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]): + 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.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(status_code, headers={'Content-Type': 'application/json'}, content=body) return response diff --git a/tests/test_decoded_payloads.py b/tests/test_decoded_payloads.py index 44709c47..90a6c1d9 100644 --- a/tests/test_decoded_payloads.py +++ b/tests/test_decoded_payloads.py @@ -2,6 +2,7 @@ from typing import Optional import unittest +from uuid import UUID from appstoreserverlibrary.models.AutoRenewStatus import AutoRenewStatus from appstoreserverlibrary.models.ConsumptionRequestReason import ConsumptionRequestReason from appstoreserverlibrary.models.Environment import Environment @@ -263,4 +264,19 @@ def check_environment_and_bundle_id(bundle_id: Optional[str], app_apple_id: Opti 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) \ No newline at end of file + 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) diff --git a/tests/test_retention_messaging.py b/tests/test_retention_messaging.py new file mode 100644 index 00000000..bfd42ae2 --- /dev/null +++ b/tests/test_retention_messaging.py @@ -0,0 +1,171 @@ +# Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +import unittest +from uuid import UUID + +from appstoreserverlibrary.models.AlternateProduct import AlternateProduct +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) + 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.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.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) From eccdb462f6153373c1b459fb9e65aeba58690f8f Mon Sep 17 00:00:00 2001 From: Riyaz Panjwani Date: Wed, 29 Oct 2025 11:51:45 -0700 Subject: [PATCH 073/101] Adding support for Get App Transaction Info endpoint --- appstoreserverlibrary/api_client.py | 72 +++++++++++++------ .../models/AppTransactionInfoResponse.py | 20 ++++++ .../appTransactionDoesNotExistError.json | 4 ++ .../models/appTransactionInfoResponse.json | 3 + .../models/invalidTransactionIdError.json | 4 ++ .../models/transactionIdNotFoundError.json | 4 ++ tests/test_api_client.py | 65 +++++++++++++++++ tests/test_api_client_async.py | 67 +++++++++++++++++ 8 files changed, 219 insertions(+), 20 deletions(-) create mode 100644 appstoreserverlibrary/models/AppTransactionInfoResponse.py create mode 100644 tests/resources/models/appTransactionDoesNotExistError.json create mode 100644 tests/resources/models/appTransactionInfoResponse.json create mode 100644 tests/resources/models/invalidTransactionIdError.json create mode 100644 tests/resources/models/transactionIdNotFoundError.json diff --git a/appstoreserverlibrary/api_client.py b/appstoreserverlibrary/api_client.py index 8b661ddd..bd583464 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -33,6 +33,7 @@ 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 @@ -528,6 +529,13 @@ class APIError(IntEnum): https://developer.apple.com/documentation/retentionmessaging/messagenotfounderror """ + 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 + """ + IMAGE_ALREADY_EXISTS = 4090000 """ An error that indicates the image identifier already exists. @@ -627,7 +635,7 @@ def _get_full_url(self, path) -> str: def _get_headers(self) -> Dict[str, str]: return { 'User-Agent': "app-store-server-library/python/1.9.0", - 'Authorization': 'Bearer ' + self._generate_token(), + 'Authorization': f'Bearer {self._generate_token()}', 'Accept': 'application/json' } @@ -697,7 +705,7 @@ 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, None) + 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: Optional[List[Status]] = None) -> StatusResponse: """ @@ -713,7 +721,7 @@ def get_all_subscription_statuses(self, transaction_id: str, status: Optional[Li 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, None) + return self._make_request(f"/inApps/v1/subscriptions/{transaction_id}", "GET", queryParameters, None, StatusResponse, None) def get_refund_history(self, transaction_id: str, revision: Optional[str]) -> RefundHistoryResponse: """ @@ -730,7 +738,7 @@ def get_refund_history(self, transaction_id: str, revision: Optional[str]) -> Re if revision is not None: queryParameters["revision"] = [revision] - return self._make_request("/inApps/v2/refund/lookup/" + transaction_id, "GET", queryParameters, None, RefundHistoryResponse, None) + return self._make_request(f"/inApps/v2/refund/lookup/{transaction_id}", "GET", queryParameters, None, RefundHistoryResponse, None) def get_status_of_subscription_renewal_date_extensions(self, request_identifier: str, product_id: str) -> MassExtendRenewalDateStatusResponse: """ @@ -742,7 +750,7 @@ def get_status_of_subscription_renewal_date_extensions(self, request_identifier: :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, None) + 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: """ @@ -753,7 +761,7 @@ 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, None) + return self._make_request(f"/inApps/v1/notifications/test/{test_notification_token}", "GET", {}, None, CheckTestNotificationResponse, None) def get_notification_history(self, pagination_token: Optional[str], notification_history_request: NotificationHistoryRequest) -> NotificationHistoryResponse: """ @@ -811,7 +819,7 @@ def get_transaction_history(self, transaction_id: str, revision: Optional[str], if transaction_history_request.revoked is not None: queryParameters["revoked"] = [str(transaction_history_request.revoked)] - return self._make_request("/inApps/" + version + "/history/" + transaction_id, "GET", queryParameters, None, HistoryResponse, None) + return self._make_request("/inApps/{}/history/{}".format(version.value, transaction_id), "GET", queryParameters, None, HistoryResponse, None) def get_transaction_info(self, transaction_id: str) -> TransactionInfoResponse: """ @@ -822,7 +830,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, None) + return self._make_request(f"/inApps/v1/transactions/{transaction_id}", "GET", {}, None, TransactionInfoResponse, None) def look_up_order_id(self, order_id: str) -> OrderLookupResponse: """ @@ -833,7 +841,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, None) + 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. @@ -853,7 +862,7 @@ def send_consumption_data(self, transaction_id: str, consumption_request: Consum :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, None) + self._make_request(f"/inApps/v1/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): """ @@ -864,7 +873,7 @@ def set_app_account_token(self, original_transaction_id: str, update_app_account :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("/inApps/v1/transactions/" + original_transaction_id + "/appAccountToken", "PUT", {}, update_app_account_token_request, None, None) + 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): """ @@ -950,6 +959,17 @@ def delete_default_message(self, product_id: str, locale: str): :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_app_transaction_info(self, transaction_id: str) -> AppTransactionInfoResponse: + """ + Get a customer's app transaction information for your app. + + :param transaction_id Any originalTransactionId, transactionId 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/{transaction_id}", "GET", {}, None, AppTransactionInfoResponse, None) class AsyncAppStoreServerAPIClient(BaseAppStoreServerAPIClient): def __init__(self, signing_key: bytes, key_id: str, issuer_id: str, bundle_id: str, environment: Environment): @@ -1003,7 +1023,7 @@ async def extend_subscription_renewal_date(self, original_transaction_id: str, e :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("/inApps/v1/subscriptions/extend/" + original_transaction_id, "PUT", {}, extend_renewal_date_request, ExtendRenewalDateResponse, None) + 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, transaction_id: str, status: Optional[List[Status]] = None) -> StatusResponse: """ @@ -1019,7 +1039,7 @@ async def get_all_subscription_statuses(self, transaction_id: str, status: Optio if status is not None: queryParameters["status"] = [s.value for s in status] - return await self._make_request("/inApps/v1/subscriptions/" + transaction_id, "GET", queryParameters, None, StatusResponse, None) + return await self._make_request(f"/inApps/v1/subscriptions/{transaction_id}", "GET", queryParameters, None, StatusResponse, None) async def get_refund_history(self, transaction_id: str, revision: Optional[str]) -> RefundHistoryResponse: """ @@ -1036,7 +1056,7 @@ async def get_refund_history(self, transaction_id: str, revision: Optional[str]) if revision is not None: queryParameters["revision"] = [revision] - return await self._make_request("/inApps/v2/refund/lookup/" + transaction_id, "GET", queryParameters, None, RefundHistoryResponse, None) + return await self._make_request(f"/inApps/v2/refund/lookup/{transaction_id}", "GET", queryParameters, None, RefundHistoryResponse, None) async def get_status_of_subscription_renewal_date_extensions(self, request_identifier: str, product_id: str) -> MassExtendRenewalDateStatusResponse: """ @@ -1048,7 +1068,7 @@ async def get_status_of_subscription_renewal_date_extensions(self, request_ident :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("/inApps/v1/subscriptions/extend/mass/" + product_id + "/" + request_identifier, "GET", {}, None, MassExtendRenewalDateStatusResponse, None) + 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: """ @@ -1059,7 +1079,7 @@ async def get_test_notification_status(self, test_notification_token: str) -> Ch :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("/inApps/v1/notifications/test/" + test_notification_token, "GET", {}, None, CheckTestNotificationResponse, None) + 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: """ @@ -1128,7 +1148,7 @@ async def get_transaction_info(self, transaction_id: str) -> TransactionInfoResp :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("/inApps/v1/transactions/" + transaction_id, "GET", {}, None, TransactionInfoResponse, None) + 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: """ @@ -1139,7 +1159,7 @@ async 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 await self._make_request("/inApps/v1/lookup/" + order_id, "GET", {}, None, OrderLookupResponse, None) + 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. @@ -1159,7 +1179,7 @@ async def send_consumption_data(self, transaction_id: str, consumption_request: :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("/inApps/v1/transactions/consumption/" + transaction_id, "PUT", {}, consumption_request, None, None) + await self._make_request(f"/inApps/v1/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): """ @@ -1170,7 +1190,7 @@ async def set_app_account_token(self, original_transaction_id: str, update_app_a :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("/inApps/v1/transactions/" + original_transaction_id + "/appAccountToken", "PUT", {}, update_app_account_token_request, None, None) + 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): """ @@ -1256,3 +1276,15 @@ async def delete_default_message(self, product_id: str, locale: str): :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_app_transaction_info(self, transaction_id: str) -> AppTransactionInfoResponse: + """ + Get a customer's app transaction information for your app. + + :param transaction_id Any originalTransactionId, transactionId 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/{transaction_id}", "GET", {}, None, AppTransactionInfoResponse, None) + 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/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/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/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/test_api_client.py b/tests/test_api_client.py index c7d8aa45..3d683c57 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -654,6 +654,71 @@ def test_delete_default_message(self): None) client.delete_default_message('com.example.product', 'en-US') + 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 get_signing_key(self): return read_data_from_binary_file('tests/resources/certs/testSigningKey.p8') diff --git a/tests/test_api_client_async.py b/tests/test_api_client_async.py index f15b3aa7..f6a9ad1b 100644 --- a/tests/test_api_client_async.py +++ b/tests/test_api_client_async.py @@ -7,6 +7,7 @@ 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.ConsumptionStatus import ConsumptionStatus @@ -657,6 +658,72 @@ async def test_delete_default_message(self): {}, None) await client.delete_default_message('com.example.product', 'en-US') + + 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) def get_signing_key(self): return read_data_from_binary_file('tests/resources/certs/testSigningKey.p8') From 57073e10c45233e6d96799e6eca455dfc20f3ff3 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Fri, 31 Oct 2025 18:42:50 -0700 Subject: [PATCH 074/101] Adding RETRYABLE_VERIFICATION_FAILURE for OCSP network failures --- appstoreserverlibrary/signed_data_verifier.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/appstoreserverlibrary/signed_data_verifier.py b/appstoreserverlibrary/signed_data_verifier.py index 581fc7d1..068f74db 100644 --- a/appstoreserverlibrary/signed_data_verifier.py +++ b/appstoreserverlibrary/signed_data_verifier.py @@ -244,12 +244,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] @@ -352,6 +358,7 @@ class VerificationStatus(IntEnum): INVALID_CHAIN_LENGTH = 4 INVALID_CHAIN = 5 INVALID_ENVIRONMENT = 6 + RETRYABLE_VERIFICATION_FAILURE = 7 class VerificationException(Exception): From cddc2f6c86ebd7443cb9562330da14b8f49f5365 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Fri, 31 Oct 2025 19:04:39 -0700 Subject: [PATCH 075/101] Add timeout to AppStoreServerAPIClient --- appstoreserverlibrary/api_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/appstoreserverlibrary/api_client.py b/appstoreserverlibrary/api_client.py index bd583464..7932d4f2 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -682,7 +682,7 @@ def _make_request(self, path: str, method: str, queryParameters: Dict[str, Union 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) + 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: """ @@ -1000,7 +1000,7 @@ async def _make_request(self, path: str, method: str, queryParameters: Dict[str, 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) + 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: """ From dd0727d6ec3a031fa054778cf3351a372de18b85 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Fri, 31 Oct 2025 19:16:04 -0700 Subject: [PATCH 076/101] Updating signing cert for recent rotation --- tests/test_x509_verifiction.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_x509_verifiction.py b/tests/test_x509_verifiction.py index 98d1af99..048d58c7 100644 --- a/tests/test_x509_verifiction.py +++ b/tests/test_x509_verifiction.py @@ -19,9 +19,9 @@ 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): From 07242a133791a1d38f4fdb1ce70e57654afd306e Mon Sep 17 00:00:00 2001 From: Ian Zanger <22856937+izanger@users.noreply.github.com> Date: Tue, 4 Nov 2025 17:43:46 -0800 Subject: [PATCH 077/101] Add IAP Offer Codes support --- .../models/JWSRenewalInfoDecodedPayload.py | 6 +++--- .../models/JWSTransactionDecodedPayload.py | 6 +++--- appstoreserverlibrary/models/OfferDiscountType.py | 5 +++-- appstoreserverlibrary/models/OfferType.py | 4 ++-- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/appstoreserverlibrary/models/JWSRenewalInfoDecodedPayload.py b/appstoreserverlibrary/models/JWSRenewalInfoDecodedPayload.py index 8a2d9ca6..5d537022 100644 --- a/appstoreserverlibrary/models/JWSRenewalInfoDecodedPayload.py +++ b/appstoreserverlibrary/models/JWSRenewalInfoDecodedPayload.py @@ -93,7 +93,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 """ @@ -105,7 +105,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 """ @@ -159,7 +159,7 @@ class JWSRenewalInfoDecodedPayload(AttrsRawValueAware): offerDiscountType: Optional[OfferDiscountType] = OfferDiscountType.create_main_attr('rawOfferDiscountType') """ - The payment mode of the discount offer. + The payment mode you configure for the offer. https://developer.apple.com/documentation/appstoreserverapi/offerdiscounttype """ diff --git a/appstoreserverlibrary/models/JWSTransactionDecodedPayload.py b/appstoreserverlibrary/models/JWSTransactionDecodedPayload.py index 38fd0a6f..bbed834d 100644 --- a/appstoreserverlibrary/models/JWSTransactionDecodedPayload.py +++ b/appstoreserverlibrary/models/JWSTransactionDecodedPayload.py @@ -132,7 +132,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 """ @@ -170,7 +170,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 """ @@ -229,7 +229,7 @@ class JWSTransactionDecodedPayload(AttrsRawValueAware): 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 """ 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 a66fd83e..48a5df66 100644 --- a/appstoreserverlibrary/models/OfferType.py +++ b/appstoreserverlibrary/models/OfferType.py @@ -6,11 +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 From ed4f70aef2bc43ee919cb73bab35a09c1a1d08e3 Mon Sep 17 00:00:00 2001 From: Riyaz Panjwani Date: Mon, 10 Nov 2025 19:53:30 -0800 Subject: [PATCH 078/101] Migrating to toml setup --- .github/workflows/ci-release-docs.yml | 2 +- pyproject.toml | 34 +++++++++++++++++++++++++++ setup.py | 25 -------------------- 3 files changed, 35 insertions(+), 26 deletions(-) create mode 100644 pyproject.toml delete mode 100644 setup.py diff --git a/.github/workflows/ci-release-docs.yml b/.github/workflows/ci-release-docs.yml index cc70d703..4b7dd427 100644 --- a/.github/workflows/ci-release-docs.yml +++ b/.github/workflows/ci-release-docs.yml @@ -20,7 +20,7 @@ jobs: 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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..f5f8b79f --- /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 = "1.9.0" +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>=40.0.0", + "pyOpenSSL>=23.1.1", + "asn1==2.8.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/setup.py b/setup.py deleted file mode 100644 index 31ba801c..00000000 --- a/setup.py +++ /dev/null @@ -1,25 +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="1.9.0", - 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', 'pyOpenSSL >= 23.1.1', 'asn1==2.8.0', 'cattrs >= 23.1.2'], - extras_require={ - "async": ["httpx"], - }, - package_data={"appstoreserverlibrary": ["py.typed"]}, - license="MIT", - classifiers=["License :: OSI Approved :: MIT License"], -) From b90549ac3239f070a536fcfcca9792e6b8fd05b6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 21 Nov 2025 02:06:00 +0000 Subject: [PATCH 079/101] Bump actions/checkout from 5 to 6 Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci-prb.yml | 2 +- .github/workflows/ci-release-docs.yml | 2 +- .github/workflows/ci-release.yml | 2 +- .github/workflows/ci-snapshot.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-prb.yml b/.github/workflows/ci-prb.yml index 517988f3..1b1529f0 100644 --- a/.github/workflows/ci-prb.yml +++ b/.github/workflows/ci-prb.yml @@ -16,7 +16,7 @@ jobs: os: [ ubuntu-latest ] steps: - name: Checkout Code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python }} uses: actions/setup-python@v6 with: diff --git a/.github/workflows/ci-release-docs.yml b/.github/workflows/ci-release-docs.yml index 4b7dd427..ac8a7e4f 100644 --- a/.github/workflows/ci-release-docs.yml +++ b/.github/workflows/ci-release-docs.yml @@ -9,7 +9,7 @@ jobs: name: Python Doc Builder runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: diff --git a/.github/workflows/ci-release.yml b/.github/workflows/ci-release.yml index d34c63cc..7acae3ff 100644 --- a/.github/workflows/ci-release.yml +++ b/.github/workflows/ci-release.yml @@ -14,7 +14,7 @@ jobs: permissions: id-token: write steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: diff --git a/.github/workflows/ci-snapshot.yml b/.github/workflows/ci-snapshot.yml index b68934f8..befa6e0e 100644 --- a/.github/workflows/ci-snapshot.yml +++ b/.github/workflows/ci-snapshot.yml @@ -11,7 +11,7 @@ jobs: name: Python Snapshot Builder runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: From c16001a30042c4be4e778b7da282ef7b661de44d Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Thu, 20 Nov 2025 17:29:13 -0800 Subject: [PATCH 080/101] Release v2.0.0 of the App Store Server Library --- CHANGELOG.md | 9 +++++++++ appstoreserverlibrary/api_client.py | 2 +- pyproject.toml | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a01f2e11..270f0ff2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 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 diff --git a/appstoreserverlibrary/api_client.py b/appstoreserverlibrary/api_client.py index 7932d4f2..acc68a3a 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -634,7 +634,7 @@ def _get_full_url(self, path) -> str: def _get_headers(self) -> Dict[str, str]: return { - 'User-Agent': "app-store-server-library/python/1.9.0", + 'User-Agent': "app-store-server-library/python/2.0.0", 'Authorization': f'Bearer {self._generate_token()}', 'Accept': 'application/json' } diff --git a/pyproject.toml b/pyproject.toml index f5f8b79f..56e100a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" [project] name = "app-store-server-library" -version = "1.9.0" +version = "2.0.0" description = "The App Store Server Library" readme = {file = "README.md", content-type = "text/markdown"} license = {text = "MIT"} From 918eee8aa2c3e9daa169062fd5570170d95fc35f Mon Sep 17 00:00:00 2001 From: Riyaz Panjwani Date: Mon, 22 Dec 2025 17:46:19 -0800 Subject: [PATCH 081/101] Updating App Store Server Library to support Consumption API and new notificationType --- appstoreserverlibrary/api_client.py | 40 ++++- appstoreserverlibrary/models/AppData.py | 49 ++++++ .../models/ConsumptionRequest.py | 127 ++------------ .../models/ConsumptionRequestV1.py | 156 ++++++++++++++++++ .../models/DeliveryStatus.py | 19 +-- .../models/DeliveryStatusV1.py | 21 +++ .../models/JWSTransactionDecodedPayload.py | 21 ++- .../models/NotificationTypeV2.py | 3 +- .../models/RefundPreference.py | 17 +- .../models/RefundPreferenceV1.py | 19 +++ .../models/ResponseBodyV2DecodedPayload.py | 9 +- .../models/RevocationType.py | 15 ++ appstoreserverlibrary/signed_data_verifier.py | 4 + tests/resources/models/appData.json | 6 + .../signedRescindConsentNotification.json | 12 ++ .../signedTransactionWithRevocation.json | 32 ++++ tests/test_api_client.py | 32 +++- tests/test_api_client_async.py | 31 +++- tests/test_app_data.py | 23 +++ tests/test_decoded_payloads.py | 72 ++++++++ 20 files changed, 565 insertions(+), 143 deletions(-) create mode 100644 appstoreserverlibrary/models/AppData.py create mode 100644 appstoreserverlibrary/models/ConsumptionRequestV1.py create mode 100644 appstoreserverlibrary/models/DeliveryStatusV1.py create mode 100644 appstoreserverlibrary/models/RefundPreferenceV1.py create mode 100644 appstoreserverlibrary/models/RevocationType.py create mode 100644 tests/resources/models/appData.json create mode 100644 tests/resources/models/signedRescindConsentNotification.json create mode 100644 tests/resources/models/signedTransactionWithRevocation.json create mode 100644 tests/test_app_data.py diff --git a/appstoreserverlibrary/api_client.py b/appstoreserverlibrary/api_client.py index acc68a3a..f4acf0b9 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -2,6 +2,7 @@ import calendar import datetime +import warnings from enum import IntEnum, Enum from typing import Any, Dict, List, MutableMapping, Optional, Type, TypeVar, Union from attr import define @@ -14,6 +15,7 @@ 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.Environment import Environment from .models.ExtendRenewalDateRequest import ExtendRenewalDateRequest @@ -853,17 +855,32 @@ def request_test_notification(self) -> 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 """ + 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. @@ -1170,17 +1187,32 @@ async def request_test_notification(self) -> SendTestNotificationResponse: """ return await self._make_request("/inApps/v1/notifications/test", "POST", {}, None, SendTestNotificationResponse, None) - async def send_consumption_data(self, transaction_id: str, consumption_request: ConsumptionRequest): + 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 + 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. 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/ConsumptionRequest.py b/appstoreserverlibrary/models/ConsumptionRequest.py index c4d0a940..ec83073a 100644 --- a/appstoreserverlibrary/models/ConsumptionRequest.py +++ b/appstoreserverlibrary/models/ConsumptionRequest.py @@ -1,153 +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 .RefundPreference import RefundPreference -from .UserStatus import UserStatus @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') - """ - 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') + https://developer.apple.com/documentation/appstoreserverapi/deliverystatus """ - 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 + consumptionPercentage: Optional[int] = attr.ib(default=None) """ + An integer that indicates the percentage, in milliunits, of the In-App Purchase the customer consumed. - 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/consumptionpercentage """ - rawLifetimeDollarsPurchased: Optional[int] = LifetimeDollarsPurchased.create_raw_attr('lifetimeDollarsPurchased') + rawDeliveryStatus: str = DeliveryStatus.create_raw_attr('deliveryStatus', required=True) """ - See lifetimeDollarsPurchased - """ - - userStatus: Optional[UserStatus] = UserStatus.create_main_attr('rawUserStatus') - """ - The status of the customer's account. - - https://developer.apple.com/documentation/appstoreserverapi/userstatus + See deliveryStatus """ - rawUserStatus: Optional[int] = UserStatus.create_raw_attr('userStatus') - """ - See userStatus + refundPreference: Optional[RefundPreference] = RefundPreference.create_main_attr('rawRefundPreference') """ + A value that indicates your preferred outcome for the refund request. - refundPreference: Optional[RefundPreference] = RefundPreference.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] = RefundPreference.create_raw_attr('refundPreference') + rawRefundPreference: Optional[str] = RefundPreference.create_raw_attr('refundPreference') """ See refundPreference - """ \ No newline at end of file + """ 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/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/JWSTransactionDecodedPayload.py b/appstoreserverlibrary/models/JWSTransactionDecodedPayload.py index bbed834d..a9cb3dbf 100644 --- a/appstoreserverlibrary/models/JWSTransactionDecodedPayload.py +++ b/appstoreserverlibrary/models/JWSTransactionDecodedPayload.py @@ -10,6 +10,7 @@ from .LibraryUtility import AttrsRawValueAware from .OfferType import OfferType from .RevocationReason import RevocationReason +from .RevocationType import RevocationType from .TransactionReason import TransactionReason from .Type import Type @@ -251,4 +252,22 @@ class JWSTransactionDecodedPayload(AttrsRawValueAware): The duration of the offer. https://developer.apple.com/documentation/appstoreserverapi/offerPeriod - """ \ No newline at end of file + """ + 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 + """ diff --git a/appstoreserverlibrary/models/NotificationTypeV2.py b/appstoreserverlibrary/models/NotificationTypeV2.py index 09a1d8b5..78266768 100644 --- a/appstoreserverlibrary/models/NotificationTypeV2.py +++ b/appstoreserverlibrary/models/NotificationTypeV2.py @@ -28,4 +28,5 @@ class NotificationTypeV2(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): RENEWAL_EXTENSION = "RENEWAL_EXTENSION" REFUND_REVERSED = "REFUND_REVERSED" EXTERNAL_PURCHASE_TOKEN = "EXTERNAL_PURCHASE_TOKEN" - ONE_TIME_CHARGE = "ONE_TIME_CHARGE" \ No newline at end of file + ONE_TIME_CHARGE = "ONE_TIME_CHARGE" + RESCIND_CONSENT = "RESCIND_CONSENT" diff --git a/appstoreserverlibrary/models/RefundPreference.py b/appstoreserverlibrary/models/RefundPreference.py index 1a890ca5..c20664e6 100644 --- a/appstoreserverlibrary/models/RefundPreference.py +++ b/appstoreserverlibrary/models/RefundPreference.py @@ -1,16 +1,15 @@ -# Copyright (c) 2024 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 RefundPreference(IntEnum, metaclass=AppStoreServerLibraryEnumMeta): +class RefundPreference(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): """ - A value that indicates your preferred outcome for the refund request. - + 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 """ - UNDECLARED = 0 - PREFER_GRANT = 1 - PREFER_DECLINE = 2 - NO_PREFERENCE = 3 + 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/ResponseBodyV2DecodedPayload.py b/appstoreserverlibrary/models/ResponseBodyV2DecodedPayload.py index 0409011d..855e89ab 100644 --- a/appstoreserverlibrary/models/ResponseBodyV2DecodedPayload.py +++ b/appstoreserverlibrary/models/ResponseBodyV2DecodedPayload.py @@ -4,6 +4,7 @@ from attr import define import attr +from .AppData import AppData from .Data import Data from .ExternalPurchaseToken import ExternalPurchaseToken from .LibraryUtility import AttrsRawValueAware @@ -87,4 +88,10 @@ class ResponseBodyV2DecodedPayload(AttrsRawValueAware): The data, summary, and externalPurchaseToken fields are mutually exclusive. The payload contains only one of these fields. https://developer.apple.com/documentation/appstoreservernotifications/externalpurchasetoken - """ \ No newline at end of file + """ + 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/signed_data_verifier.py b/appstoreserverlibrary/signed_data_verifier.py index 068f74db..f56440a7 100644 --- a/appstoreserverlibrary/signed_data_verifier.py +++ b/appstoreserverlibrary/signed_data_verifier.py @@ -105,6 +105,10 @@ def verify_and_decode_notification(self, signed_payload: str) -> ResponseBodyV2D 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 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/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/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/test_api_client.py b/tests/test_api_client.py index 3d683c57..00b597e8 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -8,8 +8,10 @@ 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 @@ -27,6 +29,7 @@ from appstoreserverlibrary.models.Platform import Platform from appstoreserverlibrary.models.PlayTime import PlayTime 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 @@ -338,7 +341,7 @@ def test_request_test_notification(self): def test_send_consumption_data(self): client = self.get_client_with_body(b'', 'PUT', - 'https://local-testing-base-url/inApps/v1/transactions/consumption/49571273', + 'https://local-testing-base-url/inApps/v1/transactions/consumption/49571273', {}, {'customerConsented': True, 'consumptionStatus': 1, @@ -353,23 +356,44 @@ def test_send_consumption_data(self): '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, - refundPreference=RefundPreference.NO_PREFERENCE + 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, + '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', diff --git a/tests/test_api_client_async.py b/tests/test_api_client_async.py index f6a9ad1b..3457cb69 100644 --- a/tests/test_api_client_async.py +++ b/tests/test_api_client_async.py @@ -10,8 +10,10 @@ 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 @@ -30,6 +32,7 @@ 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 @@ -344,7 +347,7 @@ async def test_request_test_notification(self): 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', + 'https://local-testing-base-url/inApps/v1/transactions/consumption/49571273', {}, {'customerConsented': True, 'consumptionStatus': 1, @@ -359,23 +362,43 @@ async def test_send_consumption_data(self): '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, - refundPreference=RefundPreference.NO_PREFERENCE + 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, + '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', 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 90a6c1d9..f5644613 100644 --- a/tests/test_decoded_payloads.py +++ b/tests/test_decoded_payloads.py @@ -14,6 +14,7 @@ from appstoreserverlibrary.models.PriceIncreaseStatus import PriceIncreaseStatus from appstoreserverlibrary.models.PurchasePlatform import PurchasePlatform 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 @@ -87,6 +88,53 @@ def test_transaction_decoding(self): self.assertEqual("71134", transaction.appTransactionId) self.assertEqual("P1Y", transaction.offerPeriod) + 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): signed_renewal_info = create_signed_data_from_json('tests/resources/models/signedRenewalInfo.json') @@ -280,3 +328,27 @@ def test_realtime_request_decoding(self): 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) From 751a4d7143dc590b6146f5bab49d4a1fcd24dce4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 6 Jan 2026 02:08:47 +0000 Subject: [PATCH 082/101] Bump sphinx from 8.2.3 to 9.1.0 Bumps [sphinx](https://github.com/sphinx-doc/sphinx) from 8.2.3 to 9.1.0. - [Release notes](https://github.com/sphinx-doc/sphinx/releases) - [Changelog](https://github.com/sphinx-doc/sphinx/blob/master/CHANGES.rst) - [Commits](https://github.com/sphinx-doc/sphinx/compare/v8.2.3...v9.1.0) --- updated-dependencies: - dependency-name: sphinx dependency-version: 9.1.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- docs/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 81e7e12f..bc70f9f6 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1 +1 @@ -sphinx == 8.2.3 \ No newline at end of file +sphinx == 9.1.0 \ No newline at end of file From 42c3a20d6e8b2dad699e52779af694c71cd318dc Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Wed, 11 Mar 2026 09:27:50 -0700 Subject: [PATCH 083/101] Updating AppTransaction documentation --- .../models/AppTransaction.py | 39 ++++++++++--------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/appstoreserverlibrary/models/AppTransaction.py b/appstoreserverlibrary/models/AppTransaction.py index 6d2c98c3..b8d31b6a 100644 --- a/appstoreserverlibrary/models/AppTransaction.py +++ b/appstoreserverlibrary/models/AppTransaction.py @@ -12,16 +12,17 @@ @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') @@ -33,84 +34,84 @@ 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/storekit/apptransaction/apptransactionid + 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/storekit/apptransaction/originalplatform-4mogz + https://developer.apple.com/documentation/appstoreserverapi/originalplatform """ rawOriginalPlatform: Optional[str] = PurchasePlatform.create_raw_attr('originalPlatform') From a8c83325e14999c2f7ce929c5e70ad37fc6c8b8b Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Wed, 11 Mar 2026 10:53:12 -0700 Subject: [PATCH 084/101] Adopting ASN1 3.x with much better native decoding support --- appstoreserverlibrary/receipt_utility.py | 103 ++++++----------------- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 27 insertions(+), 80 deletions(-) diff --git a/appstoreserverlibrary/receipt_utility.py b/appstoreserverlibrary/receipt_utility.py index 7c66274b..be47cc30 100644 --- a/appstoreserverlibrary/receipt_utility.py +++ b/appstoreserverlibrary/receipt_utility.py @@ -13,6 +13,12 @@ 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. @@ -21,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 or value == ORIGINAL_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]: """ @@ -99,19 +62,3 @@ def extract_transaction_id_from_transaction_receipt(self, transaction_receipt: s 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/pyproject.toml b/pyproject.toml index 56e100a5..610a3fbb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ dependencies = [ "requests>=2.28.0,<3", "cryptography>=40.0.0", "pyOpenSSL>=23.1.1", - "asn1==2.8.0", + "asn1==3.2.0", "cattrs>=23.1.2", ] diff --git a/requirements.txt b/requirements.txt index 70edeeaa..81519baf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,6 @@ PyJWT >= 2.6.0, < 3 requests >= 2.28.0, < 3 cryptography >= 40.0.0 pyOpenSSL >= 23.1.1 -asn1==2.8.0 +asn1==3.2.0 cattrs >= 23.1.2 httpx==0.28.1 From b2012542e289689c388d3694bc32fcc8409fb66b Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Wed, 11 Mar 2026 22:19:42 -0700 Subject: [PATCH 085/101] Release v3.0.0 --- CHANGELOG.md | 4 ++++ appstoreserverlibrary/api_client.py | 2 +- pyproject.toml | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 270f0ff2..d046f665 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 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 diff --git a/appstoreserverlibrary/api_client.py b/appstoreserverlibrary/api_client.py index acc68a3a..12699b89 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -634,7 +634,7 @@ def _get_full_url(self, path) -> str: def _get_headers(self) -> Dict[str, str]: return { - 'User-Agent': "app-store-server-library/python/2.0.0", + 'User-Agent': "app-store-server-library/python/3.0.0", 'Authorization': f'Bearer {self._generate_token()}', 'Accept': 'application/json' } diff --git a/pyproject.toml b/pyproject.toml index 610a3fbb..e9c5a0e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" [project] name = "app-store-server-library" -version = "2.0.0" +version = "3.0.0" description = "The App Store Server Library" readme = {file = "README.md", content-type = "text/markdown"} license = {text = "MIT"} From c593431d138ac771db41390953ccddbe204e12d0 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Thu, 12 Mar 2026 17:10:10 -0700 Subject: [PATCH 086/101] Move to latest Python version --- .github/workflows/ci-prb.yml | 2 +- .github/workflows/ci-release-docs.yml | 2 +- .github/workflows/ci-release.yml | 2 +- .github/workflows/ci-snapshot.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-prb.yml b/.github/workflows/ci-prb.yml index 517988f3..45387a1f 100644 --- a/.github/workflows/ci-prb.yml +++ b/.github/workflows/ci-prb.yml @@ -12,7 +12,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - python: [ "3.8", "3.9", "3.10", "3.11", "3.12" ] + python: [ "3.10", "3.11", "3.12", "3.13", "3.14" ] os: [ ubuntu-latest ] steps: - name: Checkout Code diff --git a/.github/workflows/ci-release-docs.yml b/.github/workflows/ci-release-docs.yml index 4b7dd427..c4dc5169 100644 --- a/.github/workflows/ci-release-docs.yml +++ b/.github/workflows/ci-release-docs.yml @@ -13,7 +13,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: "3.11" + python-version: "3.14" - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/.github/workflows/ci-release.yml b/.github/workflows/ci-release.yml index d34c63cc..0daf1108 100644 --- a/.github/workflows/ci-release.yml +++ b/.github/workflows/ci-release.yml @@ -18,7 +18,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: "3.11" + python-version: "3.14" - name: Install build run: >- python3 -m diff --git a/.github/workflows/ci-snapshot.yml b/.github/workflows/ci-snapshot.yml index b68934f8..c47b52a4 100644 --- a/.github/workflows/ci-snapshot.yml +++ b/.github/workflows/ci-snapshot.yml @@ -15,7 +15,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: "3.11" + python-version: "3.14" - name: Install build run: >- python3 -m From 723b56035903836aa245fefc14d330e20aa44508 Mon Sep 17 00:00:00 2001 From: Ohad Benita Date: Sun, 22 Mar 2026 12:44:18 +0200 Subject: [PATCH 087/101] Send consumption information: Fix missing deliveryStatus. --- appstoreserverlibrary/models/LibraryUtility.py | 12 ++++++++---- tests/test_api_client.py | 1 + tests/test_api_client_async.py | 2 +- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/appstoreserverlibrary/models/LibraryUtility.py b/appstoreserverlibrary/models/LibraryUtility.py index 7d395025..253d446f 100644 --- a/appstoreserverlibrary/models/LibraryUtility.py +++ b/appstoreserverlibrary/models/LibraryUtility.py @@ -14,6 +14,7 @@ metadata_key = 'correspondingFieldName' metadata_type_key = 'typeOfField' +metadata_required_key = 'requiredField' class AppStoreServerLibraryEnumMeta(EnumMeta): def __contains__(c, val): @@ -53,9 +54,9 @@ def factory(instance): 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'}) + 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'}) + 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): @@ -92,11 +93,14 @@ def make_overrides(cl): 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, omit_if_default=True) + 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 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 \ No newline at end of file + return c diff --git a/tests/test_api_client.py b/tests/test_api_client.py index 00b597e8..e8c38384 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -381,6 +381,7 @@ def test_send_consumption_information(self): { 'customerConsented': True, 'sampleContentProvided': False, + 'deliveryStatus': 'DELIVERED', 'consumptionPercentage': 50000, 'refundPreference': 'GRANT_FULL' }) diff --git a/tests/test_api_client_async.py b/tests/test_api_client_async.py index 3457cb69..f6153a6a 100644 --- a/tests/test_api_client_async.py +++ b/tests/test_api_client_async.py @@ -386,6 +386,7 @@ async def test_send_consumption_information(self): {}, {'customerConsented': True, 'sampleContentProvided': False, + 'deliveryStatus': 'DELIVERED', 'consumptionPercentage': 50000, 'refundPreference': 'GRANT_FULL'}) @@ -786,4 +787,3 @@ async def fake_execute_and_validate_inputs(method: bytes, url: str, params: Dict 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) - From 16875f28ef6b1603fccc8c07d5a7430fc1ab9e9d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 02:03:16 +0000 Subject: [PATCH 088/101] Bump actions/deploy-pages from 4 to 5 Bumps [actions/deploy-pages](https://github.com/actions/deploy-pages) from 4 to 5. - [Release notes](https://github.com/actions/deploy-pages/releases) - [Commits](https://github.com/actions/deploy-pages/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/deploy-pages dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci-release-docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-release-docs.yml b/.github/workflows/ci-release-docs.yml index 7620ef17..9892cf11 100644 --- a/.github/workflows/ci-release-docs.yml +++ b/.github/workflows/ci-release-docs.yml @@ -40,4 +40,4 @@ jobs: steps: - name: Deploy id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 From d4ef4946b345745715f441da3a11d08075fa72a8 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Thu, 9 Apr 2026 16:35:16 -0700 Subject: [PATCH 089/101] Adding changes for Retention Messaging API 1.3-4 https://developer.apple.com/documentation/retentionmessaging/retention-messaging-changelog --- appstoreserverlibrary/api_client.py | 237 +++++++++++++++++- appstoreserverlibrary/models/BulletPoint.py | 35 +++ .../models/DefaultConfigurationRequest.py | 2 + .../models/DefaultConfigurationResponse.py | 21 ++ .../models/GetImageListResponseItem.py | 13 + .../models/HeaderPosition.py | 14 ++ appstoreserverlibrary/models/ImageSize.py | 14 ++ .../models/PerformanceTestConfig.py | 49 ++++ .../models/PerformanceTestRequest.py | 19 ++ .../models/PerformanceTestResponse.py | 30 +++ .../models/PerformanceTestResponseTimes.py | 49 ++++ .../models/PerformanceTestResultResponse.py | 96 +++++++ .../models/PerformanceTestStatus.py | 15 ++ .../models/RealtimeUrlRequest.py | 19 ++ .../models/RealtimeUrlResponse.py | 21 ++ .../models/UploadMessageRequestBody.py | 26 +- .../models/getDefaultMessageResponse.json | 3 + .../models/getImageListResponse.json | 3 +- .../models/getRealtimeUrlResponse.json | 3 + .../models/performanceTestResponse.json | 10 + .../models/performanceTestResultResponse.json | 24 ++ tests/test_api_client.py | 114 +++++++++ tests/test_api_client_async.py | 116 ++++++++- 23 files changed, 923 insertions(+), 10 deletions(-) create mode 100644 appstoreserverlibrary/models/BulletPoint.py create mode 100644 appstoreserverlibrary/models/DefaultConfigurationResponse.py create mode 100644 appstoreserverlibrary/models/HeaderPosition.py create mode 100644 appstoreserverlibrary/models/ImageSize.py create mode 100644 appstoreserverlibrary/models/PerformanceTestConfig.py create mode 100644 appstoreserverlibrary/models/PerformanceTestRequest.py create mode 100644 appstoreserverlibrary/models/PerformanceTestResponse.py create mode 100644 appstoreserverlibrary/models/PerformanceTestResponseTimes.py create mode 100644 appstoreserverlibrary/models/PerformanceTestResultResponse.py create mode 100644 appstoreserverlibrary/models/PerformanceTestStatus.py create mode 100644 appstoreserverlibrary/models/RealtimeUrlRequest.py create mode 100644 appstoreserverlibrary/models/RealtimeUrlResponse.py create mode 100644 tests/resources/models/getDefaultMessageResponse.json create mode 100644 tests/resources/models/getRealtimeUrlResponse.json create mode 100644 tests/resources/models/performanceTestResponse.json create mode 100644 tests/resources/models/performanceTestResultResponse.json diff --git a/appstoreserverlibrary/api_client.py b/appstoreserverlibrary/api_client.py index 12699b89..89238c07 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -15,18 +15,25 @@ from .models.CheckTestNotificationResponse import CheckTestNotificationResponse from .models.ConsumptionRequest import ConsumptionRequest 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 @@ -389,6 +396,62 @@ class APIError(IntEnum): 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. @@ -445,6 +508,13 @@ class APIError(IntEnum): 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. @@ -529,6 +599,13 @@ class APIError(IntEnum): 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. @@ -536,6 +613,20 @@ class APIError(IntEnum): 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. @@ -875,16 +966,20 @@ def set_app_account_token(self, original_transaction_id: str, update_app_account """ 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): + 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 """ - self._make_request(f"/inApps/v1/messaging/image/{image_identifier}", "PUT", {}, image, None, "image/png") + 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): """ @@ -959,7 +1054,70 @@ def delete_default_message(self, product_id: str, locale: str): :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, transaction_id: str) -> AppTransactionInfoResponse: """ Get a customer's app transaction information for your app. @@ -1192,16 +1350,20 @@ async def set_app_account_token(self, original_transaction_id: str, update_app_a """ 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): + 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 """ - await self._make_request(f"/inApps/v1/messaging/image/{image_identifier}", "PUT", {}, image, None, "image/png") + 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): """ @@ -1276,7 +1438,70 @@ async def delete_default_message(self, product_id: str, locale: str): :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, transaction_id: str) -> AppTransactionInfoResponse: """ Get a customer's app transaction information for your app. 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/DefaultConfigurationRequest.py b/appstoreserverlibrary/models/DefaultConfigurationRequest.py index f7f3d6bc..2660163a 100644 --- a/appstoreserverlibrary/models/DefaultConfigurationRequest.py +++ b/appstoreserverlibrary/models/DefaultConfigurationRequest.py @@ -18,5 +18,7 @@ class DefaultConfigurationRequest: """ 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/GetImageListResponseItem.py b/appstoreserverlibrary/models/GetImageListResponseItem.py index 4f00945e..4c582a3a 100644 --- a/appstoreserverlibrary/models/GetImageListResponseItem.py +++ b/appstoreserverlibrary/models/GetImageListResponseItem.py @@ -6,6 +6,7 @@ from attr import define import attr +from .ImageSize import ImageSize from .ImageState import ImageState from .LibraryUtility import AttrsRawValueAware @@ -34,4 +35,16 @@ class GetImageListResponseItem(AttrsRawValueAware): 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/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/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/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/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/UploadMessageRequestBody.py b/appstoreserverlibrary/models/UploadMessageRequestBody.py index 7d9472e4..2cc17f67 100644 --- a/appstoreserverlibrary/models/UploadMessageRequestBody.py +++ b/appstoreserverlibrary/models/UploadMessageRequestBody.py @@ -1,14 +1,17 @@ # Copyright (c) 2025 Apple Inc. Licensed under MIT License. -from typing import Optional +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: +class UploadMessageRequestBody(AttrsRawValueAware): """ The request body for uploading a message, which includes the message text and an optional image reference. @@ -35,3 +38,22 @@ class UploadMessageRequestBody: 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/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 index 652d2e96..b668dd12 100644 --- a/tests/resources/models/getImageListResponse.json +++ b/tests/resources/models/getImageListResponse.json @@ -2,7 +2,8 @@ "imageIdentifiers": [ { "imageIdentifier": "a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890", - "imageState": "APPROVED" + "imageState": "APPROVED", + "imageSize": "FULL_SIZE" } ] } 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/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/test_api_client.py b/tests/test_api_client.py index 3d683c57..c0fbb7d9 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -36,8 +36,14 @@ 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 @@ -597,6 +603,7 @@ def test_get_image_list(self): 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'', @@ -654,6 +661,113 @@ def test_delete_default_message(self): 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', diff --git a/tests/test_api_client_async.py b/tests/test_api_client_async.py index f6a9ad1b..409e9e8e 100644 --- a/tests/test_api_client_async.py +++ b/tests/test_api_client_async.py @@ -42,8 +42,14 @@ 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 @@ -602,6 +608,7 @@ async def test_get_image_list(self): 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'', @@ -658,7 +665,114 @@ async def test_delete_default_message(self): {}, 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', From 8fcc6a560c0062dfc04459bf5ae8f7ccf5e487cf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 02:03:52 +0000 Subject: [PATCH 090/101] Bump actions/upload-pages-artifact from 4 to 5 Bumps [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) from 4 to 5. - [Release notes](https://github.com/actions/upload-pages-artifact/releases) - [Commits](https://github.com/actions/upload-pages-artifact/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/upload-pages-artifact dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci-release-docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-release-docs.yml b/.github/workflows/ci-release-docs.yml index 7620ef17..d86c6c76 100644 --- a/.github/workflows/ci-release-docs.yml +++ b/.github/workflows/ci-release-docs.yml @@ -24,7 +24,7 @@ jobs: - name: Spinx build run: sphinx-build -b html _staging _build - name: Upload docs - uses: actions/upload-pages-artifact@v4 + uses: actions/upload-pages-artifact@v5 with: path: _build deploy: From 87392459e17766ec96ad4ff34b77d976178f3021 Mon Sep 17 00:00:00 2001 From: Riyaz Panjwani Date: Fri, 30 Jan 2026 14:27:34 -0800 Subject: [PATCH 091/101] Adding Python objects --- README.md | 2 +- .../AbstractAdvancedCommerceBaseItem.py | 18 + .../AbstractAdvancedCommerceInAppRequest.py | 14 + .../models/AbstractAdvancedCommerceItem.py | 23 + .../AbstractAdvancedCommerceResponse.py | 23 + .../models/AdvancedCommerceDescriptors.py | 27 + .../models/AdvancedCommerceEffective.py | 14 + .../models/AdvancedCommerceOffer.py | 50 ++ .../models/AdvancedCommerceOfferPeriod.py | 21 + .../models/AdvancedCommerceOfferReason.py | 15 + ...ancedCommerceOneTimeChargeCreateRequest.py | 55 ++ .../AdvancedCommerceOneTimeChargeItem.py | 21 + .../models/AdvancedCommercePeriod.py | 19 + .../models/AdvancedCommerceReason.py | 15 + .../models/AdvancedCommerceRefundReason.py | 20 + .../models/AdvancedCommerceRefundType.py | 15 + .../models/AdvancedCommerceRequest.py | 18 + .../models/AdvancedCommerceRequestInfo.py | 32 + .../AdvancedCommerceRequestRefundItem.py | 49 ++ .../AdvancedCommerceRequestRefundRequest.py | 40 ++ .../AdvancedCommerceRequestRefundResponse.py | 13 + ...vancedCommerceSubscriptionCancelRequest.py | 21 + ...ancedCommerceSubscriptionCancelResponse.py | 14 + ...ceSubscriptionChangeMetadataDescriptors.py | 48 ++ ...dCommerceSubscriptionChangeMetadataItem.py | 54 ++ ...mmerceSubscriptionChangeMetadataRequest.py | 39 ++ ...merceSubscriptionChangeMetadataResponse.py | 13 + .../AdvancedCommerceSubscriptionCreateItem.py | 26 + ...vancedCommerceSubscriptionCreateRequest.py | 62 ++ ...dCommerceSubscriptionMigrateDescriptors.py | 13 + ...AdvancedCommerceSubscriptionMigrateItem.py | 13 + ...dCommerceSubscriptionMigrateRenewalItem.py | 13 + ...ancedCommerceSubscriptionMigrateRequest.py | 54 ++ ...ncedCommerceSubscriptionMigrateResponse.py | 15 + ...vancedCommerceSubscriptionModifyAddItem.py | 33 + ...cedCommerceSubscriptionModifyChangeItem.py | 56 ++ ...edCommerceSubscriptionModifyDescriptors.py | 36 ++ ...dCommerceSubscriptionModifyInAppRequest.py | 75 +++ ...dCommerceSubscriptionModifyPeriodChange.py | 36 ++ ...cedCommerceSubscriptionModifyRemoveItem.py | 13 + ...ncedCommerceSubscriptionPriceChangeItem.py | 25 + ...dCommerceSubscriptionPriceChangeRequest.py | 36 ++ ...CommerceSubscriptionPriceChangeResponse.py | 15 + ...merceSubscriptionReactivateInAppRequest.py | 33 + ...ancedCommerceSubscriptionReactivateItem.py | 13 + ...vancedCommerceSubscriptionRevokeRequest.py | 44 ++ ...ancedCommerceSubscriptionRevokeResponse.py | 15 + .../models/AdvancedCommerceValidationUtils.py | 101 +++ .../models/advancedCommerceDescriptors.json | 4 + .../models/advancedCommerceOffer.json | 6 + ...cedCommerceOneTimeChargeCreateRequest.json | 16 + .../advancedCommerceOneTimeChargeItem.json | 6 + .../models/advancedCommerceRequestInfo.json | 5 + .../advancedCommerceRequestRefundItem.json | 7 + .../advancedCommerceRequestRefundRequest.json | 22 + ...advancedCommerceRequestRefundResponse.json | 3 + ...ncedCommerceSubscriptionCancelRequest.json | 6 + ...cedCommerceSubscriptionCancelResponse.json | 4 + ...SubscriptionChangeMetadataDescriptors.json | 5 + ...ommerceSubscriptionChangeMetadataItem.json | 7 + ...erceSubscriptionChangeMetadataRequest.json | 14 + ...rceSubscriptionChangeMetadataResponse.json | 4 + ...dvancedCommerceSubscriptionCreateItem.json | 6 + ...ncedCommerceSubscriptionCreateRequest.json | 28 + ...ommerceSubscriptionMigrateDescriptors.json | 4 + ...vancedCommerceSubscriptionMigrateItem.json | 5 + ...ommerceSubscriptionMigrateRenewalItem.json | 5 + ...cedCommerceSubscriptionMigrateRequest.json | 18 + ...edCommerceSubscriptionMigrateResponse.json | 4 + ...ncedCommerceSubscriptionModifyAddItem.json | 6 + ...dCommerceSubscriptionModifyChangeItem.json | 9 + ...CommerceSubscriptionModifyDescriptors.json | 5 + ...ommerceSubscriptionModifyInAppRequest.json | 14 + ...ommerceSubscriptionModifyPeriodChange.json | 4 + ...dCommerceSubscriptionModifyRemoveItem.json | 3 + ...edCommerceSubscriptionPriceChangeItem.json | 5 + ...ommerceSubscriptionPriceChangeRequest.json | 12 + ...mmerceSubscriptionPriceChangeResponse.json | 4 + ...rceSubscriptionReactivateInAppRequest.json | 11 + ...cedCommerceSubscriptionReactivateItem.json | 3 + ...ncedCommerceSubscriptionRevokeRequest.json | 9 + ...cedCommerceSubscriptionRevokeResponse.json | 4 + tests/test_advanced_commerce_models.py | 611 ++++++++++++++++++ 83 files changed, 2298 insertions(+), 1 deletion(-) create mode 100644 appstoreserverlibrary/models/AbstractAdvancedCommerceBaseItem.py create mode 100644 appstoreserverlibrary/models/AbstractAdvancedCommerceInAppRequest.py create mode 100644 appstoreserverlibrary/models/AbstractAdvancedCommerceItem.py create mode 100644 appstoreserverlibrary/models/AbstractAdvancedCommerceResponse.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceDescriptors.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceEffective.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceOffer.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceOfferPeriod.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceOfferReason.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceOneTimeChargeCreateRequest.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceOneTimeChargeItem.py create mode 100644 appstoreserverlibrary/models/AdvancedCommercePeriod.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceReason.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceRefundReason.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceRefundType.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceRequest.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceRequestInfo.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceRequestRefundItem.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceRequestRefundRequest.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceRequestRefundResponse.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceSubscriptionCancelRequest.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceSubscriptionCancelResponse.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceSubscriptionChangeMetadataDescriptors.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceSubscriptionChangeMetadataItem.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceSubscriptionChangeMetadataRequest.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceSubscriptionChangeMetadataResponse.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceSubscriptionCreateItem.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceSubscriptionCreateRequest.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceSubscriptionMigrateDescriptors.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceSubscriptionMigrateItem.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceSubscriptionMigrateRenewalItem.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceSubscriptionMigrateRequest.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceSubscriptionMigrateResponse.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceSubscriptionModifyAddItem.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceSubscriptionModifyChangeItem.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceSubscriptionModifyDescriptors.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceSubscriptionModifyInAppRequest.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceSubscriptionModifyPeriodChange.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceSubscriptionModifyRemoveItem.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceSubscriptionPriceChangeItem.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceSubscriptionPriceChangeRequest.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceSubscriptionPriceChangeResponse.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceSubscriptionReactivateInAppRequest.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceSubscriptionReactivateItem.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceSubscriptionRevokeRequest.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceSubscriptionRevokeResponse.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceValidationUtils.py create mode 100644 tests/resources/models/advancedCommerceDescriptors.json create mode 100644 tests/resources/models/advancedCommerceOffer.json create mode 100644 tests/resources/models/advancedCommerceOneTimeChargeCreateRequest.json create mode 100644 tests/resources/models/advancedCommerceOneTimeChargeItem.json create mode 100644 tests/resources/models/advancedCommerceRequestInfo.json create mode 100644 tests/resources/models/advancedCommerceRequestRefundItem.json create mode 100644 tests/resources/models/advancedCommerceRequestRefundRequest.json create mode 100644 tests/resources/models/advancedCommerceRequestRefundResponse.json create mode 100644 tests/resources/models/advancedCommerceSubscriptionCancelRequest.json create mode 100644 tests/resources/models/advancedCommerceSubscriptionCancelResponse.json create mode 100644 tests/resources/models/advancedCommerceSubscriptionChangeMetadataDescriptors.json create mode 100644 tests/resources/models/advancedCommerceSubscriptionChangeMetadataItem.json create mode 100644 tests/resources/models/advancedCommerceSubscriptionChangeMetadataRequest.json create mode 100644 tests/resources/models/advancedCommerceSubscriptionChangeMetadataResponse.json create mode 100644 tests/resources/models/advancedCommerceSubscriptionCreateItem.json create mode 100644 tests/resources/models/advancedCommerceSubscriptionCreateRequest.json create mode 100644 tests/resources/models/advancedCommerceSubscriptionMigrateDescriptors.json create mode 100644 tests/resources/models/advancedCommerceSubscriptionMigrateItem.json create mode 100644 tests/resources/models/advancedCommerceSubscriptionMigrateRenewalItem.json create mode 100644 tests/resources/models/advancedCommerceSubscriptionMigrateRequest.json create mode 100644 tests/resources/models/advancedCommerceSubscriptionMigrateResponse.json create mode 100644 tests/resources/models/advancedCommerceSubscriptionModifyAddItem.json create mode 100644 tests/resources/models/advancedCommerceSubscriptionModifyChangeItem.json create mode 100644 tests/resources/models/advancedCommerceSubscriptionModifyDescriptors.json create mode 100644 tests/resources/models/advancedCommerceSubscriptionModifyInAppRequest.json create mode 100644 tests/resources/models/advancedCommerceSubscriptionModifyPeriodChange.json create mode 100644 tests/resources/models/advancedCommerceSubscriptionModifyRemoveItem.json create mode 100644 tests/resources/models/advancedCommerceSubscriptionPriceChangeItem.json create mode 100644 tests/resources/models/advancedCommerceSubscriptionPriceChangeRequest.json create mode 100644 tests/resources/models/advancedCommerceSubscriptionPriceChangeResponse.json create mode 100644 tests/resources/models/advancedCommerceSubscriptionReactivateInAppRequest.json create mode 100644 tests/resources/models/advancedCommerceSubscriptionReactivateItem.json create mode 100644 tests/resources/models/advancedCommerceSubscriptionRevokeRequest.json create mode 100644 tests/resources/models/advancedCommerceSubscriptionRevokeResponse.json create mode 100644 tests/test_advanced_commerce_models.py diff --git a/README.md b/README.md index 24dfa085..1fa307c5 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # Apple App Store Server Python Library -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), and [Retention Messaging API](https://developer.apple.com/documentation/retentionmessaging). 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. [Installation](#installation) diff --git a/appstoreserverlibrary/models/AbstractAdvancedCommerceBaseItem.py b/appstoreserverlibrary/models/AbstractAdvancedCommerceBaseItem.py new file mode 100644 index 00000000..b6b4dd50 --- /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 .AdvancedCommerceValidationUtils import AdvancedCommerceValidationUtils +from .LibraryUtility import AttrsRawValueAware + +@define +class AbstractAdvancedCommerceBaseItem(AttrsRawValueAware, ABC): + SKU: str = attr.ib(validator=AdvancedCommerceValidationUtils.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..9013bad1 --- /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 .AdvancedCommerceValidationUtils import AdvancedCommerceValidationUtils + +@define +class AbstractAdvancedCommerceItem(AbstractAdvancedCommerceBaseItem): + description: str = attr.ib(validator=AdvancedCommerceValidationUtils.description_validator) + """ + A string you provide that describes a SKU. + + https://developer.apple.com/documentation/advancedcommerceapi/description + """ + + displayName: str = attr.ib(validator=AdvancedCommerceValidationUtils.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..9ff9354f --- /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 .AdvancedCommerceValidationUtils import AdvancedCommerceValidationUtils + +@define +class AdvancedCommerceDescriptors: + """ + The display name and description of a subscription product. + + https://developer.apple.com/documentation/advancedcommerceapi/descriptors + """ + description: str = attr.ib(validator=AdvancedCommerceValidationUtils.description_validator) + """ + A string you provide that describes a SKU. + + https://developer.apple.com/documentation/advancedcommerceapi/description + """ + + displayName: str = attr.ib(validator=AdvancedCommerceValidationUtils.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/AdvancedCommerceOffer.py b/appstoreserverlibrary/models/AdvancedCommerceOffer.py new file mode 100644 index 00000000..50f7d092 --- /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 .AdvancedCommerceValidationUtils import AdvancedCommerceValidationUtils +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=AdvancedCommerceValidationUtils.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/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/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/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..6236d38b --- /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 .AdvancedCommerceValidationUtils import AdvancedCommerceValidationUtils + +@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=AdvancedCommerceValidationUtils.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..1f960d4d --- /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 .AdvancedCommerceValidationUtils import AdvancedCommerceValidationUtils + +@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(AdvancedCommerceValidationUtils.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(AdvancedCommerceValidationUtils.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..2c5a829b --- /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 .AdvancedCommerceValidationUtils import AdvancedCommerceValidationUtils + +@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=AdvancedCommerceValidationUtils.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(AdvancedCommerceValidationUtils.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(AdvancedCommerceValidationUtils.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(AdvancedCommerceValidationUtils.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..19d8c1dc --- /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 .AdvancedCommerceValidationUtils import AdvancedCommerceValidationUtils + +@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(AdvancedCommerceValidationUtils.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..aafb3622 --- /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 .AdvancedCommerceValidationUtils import AdvancedCommerceValidationUtils +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=AdvancedCommerceValidationUtils.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(AdvancedCommerceValidationUtils.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..dbc5228e --- /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 .AdvancedCommerceValidationUtils import AdvancedCommerceValidationUtils + + +@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=AdvancedCommerceValidationUtils.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..d44d4990 --- /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 .AdvancedCommerceValidationUtils import AdvancedCommerceValidationUtils + +@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(AdvancedCommerceValidationUtils.description_validator)) + """ + https://developer.apple.com/documentation/advancedcommerceapi/description + """ + + displayName: Optional[str] = attr.ib(default=None, validator=attr.validators.optional(AdvancedCommerceValidationUtils.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..bec1d218 --- /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 .AdvancedCommerceValidationUtils import AdvancedCommerceValidationUtils +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(AdvancedCommerceValidationUtils.items_validator)) + """ + https://developer.apple.com/documentation/advancedcommerceapi/subscriptionmodifyadditem + """ + + changeItems: Optional[List[AdvancedCommerceSubscriptionModifyChangeItem]] = attr.ib(default=None, validator=attr.validators.optional(AdvancedCommerceValidationUtils.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..cc2212e2 --- /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 .AdvancedCommerceValidationUtils import AdvancedCommerceValidationUtils + +@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(AdvancedCommerceValidationUtils.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/AdvancedCommerceValidationUtils.py b/appstoreserverlibrary/models/AdvancedCommerceValidationUtils.py new file mode 100644 index 00000000..5b1ea638 --- /dev/null +++ b/appstoreserverlibrary/models/AdvancedCommerceValidationUtils.py @@ -0,0 +1,101 @@ +# Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +from typing import TypeVar + +T = TypeVar('T') + + +class AdvancedCommerceValidationUtils: + 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) > AdvancedCommerceValidationUtils.MAXIMUM_DESCRIPTION_LENGTH: + raise ValueError( + f"Description length cannot exceed " + f"{AdvancedCommerceValidationUtils.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) > AdvancedCommerceValidationUtils.MAXIMUM_DISPLAY_NAME_LENGTH: + raise ValueError( + f"Display name length cannot exceed " + f"{AdvancedCommerceValidationUtils.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) > AdvancedCommerceValidationUtils.MAXIMUM_SKU_LENGTH: + raise ValueError( + f"SKU length cannot exceed " + f"{AdvancedCommerceValidationUtils.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 < AdvancedCommerceValidationUtils.MIN_PERIOD or + value > AdvancedCommerceValidationUtils.MAX_PERIOD): + raise ValueError( + f"Period count must be between " + f"{AdvancedCommerceValidationUtils.MIN_PERIOD} and " + f"{AdvancedCommerceValidationUtils.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) > AdvancedCommerceValidationUtils.MAXIMUM_SKU_LENGTH: + raise ValueError( + f"SKU length cannot exceed " + f"{AdvancedCommerceValidationUtils.MAXIMUM_SKU_LENGTH} characters" + ) 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..d9874797 --- /dev/null +++ b/tests/resources/models/advancedCommerceOneTimeChargeCreateRequest.json @@ -0,0 +1,16 @@ +{ + "currency": "USD", + "item": { + "description": "description", + "displayName": "display name", + "SKU": "sku", + "price": 10000 + }, + "requestInfo": { + "requestReferenceId": "550e8400-e29b-41d4-a716-446655440000" + }, + "taxCode": "taxCode", + "storefront": "USA", + "operation": "CREATE", + "version": "1.0" +} 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/test_advanced_commerce_models.py b/tests/test_advanced_commerce_models.py new file mode 100644 index 00000000..660bf7f0 --- /dev/null +++ b/tests/test_advanced_commerce_models.py @@ -0,0 +1,611 @@ +# 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.AdvancedCommerceReason import AdvancedCommerceReason +from appstoreserverlibrary.models.AdvancedCommerceRefundReason import AdvancedCommerceRefundReason +from appstoreserverlibrary.models.AdvancedCommerceRefundType import AdvancedCommerceRefundType +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.AdvancedCommerceValidationUtils import AdvancedCommerceValidationUtils +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" + AdvancedCommerceValidationUtils.description_validator(None, None, valid_description) + + max_length_description = "A" * 45 + AdvancedCommerceValidationUtils.description_validator(None, None, max_length_description) + + too_long_description = "A" * 46 + with self.assertRaises(ValueError): + AdvancedCommerceValidationUtils.description_validator(None, None, too_long_description) + + def test_validation_utils_display_name(self): + valid_display_name = "Valid Name" + AdvancedCommerceValidationUtils.display_name_validator(None, None, valid_display_name) + + max_length_display_name = "A" * 30 + AdvancedCommerceValidationUtils.display_name_validator(None, None, max_length_display_name) + + too_long_display_name = "A" * 31 + with self.assertRaises(ValueError): + AdvancedCommerceValidationUtils.display_name_validator(None, None, too_long_display_name) + + def test_validation_utils_sku(self): + valid_sku = "valid.sku.123" + AdvancedCommerceValidationUtils.sku_validator(None, None, valid_sku) + + max_length_sku = "A" * 128 + AdvancedCommerceValidationUtils.sku_validator(None, None, max_length_sku) + + too_long_sku = "A" * 129 + with self.assertRaises(ValueError): + AdvancedCommerceValidationUtils.sku_validator(None, None, too_long_sku) + + def test_validation_utils_period_count(self): + AdvancedCommerceValidationUtils.period_count_validator(None, None, 1) + AdvancedCommerceValidationUtils.period_count_validator(None, None, 6) + AdvancedCommerceValidationUtils.period_count_validator(None, None, 12) + + with self.assertRaises(ValueError): + AdvancedCommerceValidationUtils.period_count_validator(None, None, 0) + + with self.assertRaises(ValueError): + AdvancedCommerceValidationUtils.period_count_validator(None, None, 13) + + def test_validation_utils_items(self): + valid_list = [ + AdvancedCommerceOneTimeChargeItem( + description="desc", + displayName="name", + SKU="sku1", + price=1000 + ) + ] + AdvancedCommerceValidationUtils.items_validator(None, None, valid_list) + + with self.assertRaises(ValueError): + AdvancedCommerceValidationUtils.items_validator(None, None, None) + + with self.assertRaises(ValueError): + AdvancedCommerceValidationUtils.items_validator(None, None, []) + + list_with_none = [None] + with self.assertRaises(ValueError): + AdvancedCommerceValidationUtils.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) From 8652c9a0fec2531ade782c4fd073bdb0e3402356 Mon Sep 17 00:00:00 2001 From: Riyaz Panjwani Date: Fri, 1 May 2026 17:23:51 -0700 Subject: [PATCH 092/101] Updates to AdvancedCommerce objects and adding support for monthly subs with a 12-month commitment --- .../AbstractAdvancedCommerceBaseItem.py | 4 +- .../models/AbstractAdvancedCommerceItem.py | 6 +- .../models/AdvancedCommerceDescriptors.py | 6 +- .../models/AdvancedCommerceInfo.py | 29 ++++++ .../models/AdvancedCommerceOffer.py | 4 +- .../AdvancedCommercePriceIncreaseInfo.py | 35 +++++++ ...AdvancedCommercePriceIncreaseInfoStatus.py | 13 +++ .../models/AdvancedCommerceRefund.py | 46 +++++++++ .../models/AdvancedCommerceRenewalInfo.py | 52 ++++++++++ .../models/AdvancedCommerceRenewalItem.py | 45 +++++++++ .../AdvancedCommerceRequestRefundRequest.py | 4 +- ...ceSubscriptionChangeMetadataDescriptors.py | 6 +- ...dCommerceSubscriptionChangeMetadataItem.py | 10 +- ...mmerceSubscriptionChangeMetadataRequest.py | 4 +- ...ancedCommerceSubscriptionMigrateRequest.py | 6 +- ...cedCommerceSubscriptionModifyChangeItem.py | 4 +- ...edCommerceSubscriptionModifyDescriptors.py | 6 +- ...dCommerceSubscriptionModifyInAppRequest.py | 6 +- ...ncedCommerceSubscriptionPriceChangeItem.py | 4 +- .../models/AdvancedCommerceTransactionInfo.py | 62 ++++++++++++ .../models/AdvancedCommerceTransactionItem.py | 50 ++++++++++ .../models/AlternateProduct.py | 15 ++- .../models/BillingPlanType.py | 12 +++ ...ationUtils.py => HelperValidationUtils.py} | 26 ++--- .../models/JWSRenewalInfoDecodedPayload.py | 27 ++++- .../models/JWSTransactionDecodedPayload.py | 25 +++++ .../models/NotificationTypeV2.py | 3 + .../models/RealtimeResponseBody.py | 8 ++ .../models/RenewalBillingPlanType.py | 12 +++ .../models/RenewalCommitmentInfo.py | 51 ++++++++++ .../models/TransactionCommitmentInfo.py | 34 +++++++ tests/resources/models/signedRenewalInfo.json | 36 ++++++- tests/resources/models/signedTransaction.json | 38 ++++++- tests/test_advanced_commerce_models.py | 99 +++++++++++++++---- tests/test_decoded_payloads.py | 76 ++++++++++++++ tests/test_retention_messaging.py | 32 +++++- 36 files changed, 824 insertions(+), 72 deletions(-) create mode 100644 appstoreserverlibrary/models/AdvancedCommerceInfo.py create mode 100644 appstoreserverlibrary/models/AdvancedCommercePriceIncreaseInfo.py create mode 100644 appstoreserverlibrary/models/AdvancedCommercePriceIncreaseInfoStatus.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceRefund.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceRenewalInfo.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceRenewalItem.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceTransactionInfo.py create mode 100644 appstoreserverlibrary/models/AdvancedCommerceTransactionItem.py create mode 100644 appstoreserverlibrary/models/BillingPlanType.py rename appstoreserverlibrary/models/{AdvancedCommerceValidationUtils.py => HelperValidationUtils.py} (72%) create mode 100644 appstoreserverlibrary/models/RenewalBillingPlanType.py create mode 100644 appstoreserverlibrary/models/RenewalCommitmentInfo.py create mode 100644 appstoreserverlibrary/models/TransactionCommitmentInfo.py diff --git a/appstoreserverlibrary/models/AbstractAdvancedCommerceBaseItem.py b/appstoreserverlibrary/models/AbstractAdvancedCommerceBaseItem.py index b6b4dd50..87278533 100644 --- a/appstoreserverlibrary/models/AbstractAdvancedCommerceBaseItem.py +++ b/appstoreserverlibrary/models/AbstractAdvancedCommerceBaseItem.py @@ -5,12 +5,12 @@ from attr import define import attr -from .AdvancedCommerceValidationUtils import AdvancedCommerceValidationUtils +from .HelperValidationUtils import HelperValidationUtils from .LibraryUtility import AttrsRawValueAware @define class AbstractAdvancedCommerceBaseItem(AttrsRawValueAware, ABC): - SKU: str = attr.ib(validator=AdvancedCommerceValidationUtils.sku_validator) + SKU: str = attr.ib(validator=HelperValidationUtils.sku_validator) """ The product identifier of an in-app purchase product you manage in your own system. diff --git a/appstoreserverlibrary/models/AbstractAdvancedCommerceItem.py b/appstoreserverlibrary/models/AbstractAdvancedCommerceItem.py index 9013bad1..aefb8ef3 100644 --- a/appstoreserverlibrary/models/AbstractAdvancedCommerceItem.py +++ b/appstoreserverlibrary/models/AbstractAdvancedCommerceItem.py @@ -4,18 +4,18 @@ import attr from .AbstractAdvancedCommerceBaseItem import AbstractAdvancedCommerceBaseItem -from .AdvancedCommerceValidationUtils import AdvancedCommerceValidationUtils +from .HelperValidationUtils import HelperValidationUtils @define class AbstractAdvancedCommerceItem(AbstractAdvancedCommerceBaseItem): - description: str = attr.ib(validator=AdvancedCommerceValidationUtils.description_validator) + 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=AdvancedCommerceValidationUtils.display_name_validator) + 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. diff --git a/appstoreserverlibrary/models/AdvancedCommerceDescriptors.py b/appstoreserverlibrary/models/AdvancedCommerceDescriptors.py index 9ff9354f..ff5a5eb4 100644 --- a/appstoreserverlibrary/models/AdvancedCommerceDescriptors.py +++ b/appstoreserverlibrary/models/AdvancedCommerceDescriptors.py @@ -3,7 +3,7 @@ from attr import define import attr -from .AdvancedCommerceValidationUtils import AdvancedCommerceValidationUtils +from .HelperValidationUtils import HelperValidationUtils @define class AdvancedCommerceDescriptors: @@ -12,14 +12,14 @@ class AdvancedCommerceDescriptors: https://developer.apple.com/documentation/advancedcommerceapi/descriptors """ - description: str = attr.ib(validator=AdvancedCommerceValidationUtils.description_validator) + 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=AdvancedCommerceValidationUtils.display_name_validator) + 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. 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 index 50f7d092..0b18fba3 100644 --- a/appstoreserverlibrary/models/AdvancedCommerceOffer.py +++ b/appstoreserverlibrary/models/AdvancedCommerceOffer.py @@ -6,7 +6,7 @@ from .AdvancedCommerceOfferPeriod import AdvancedCommerceOfferPeriod from .AdvancedCommerceOfferReason import AdvancedCommerceOfferReason -from .AdvancedCommerceValidationUtils import AdvancedCommerceValidationUtils +from .HelperValidationUtils import HelperValidationUtils from .LibraryUtility import AttrsRawValueAware @define @@ -17,7 +17,7 @@ class AdvancedCommerceOffer(AttrsRawValueAware): https://developer.apple.com/documentation/advancedcommerceapi/offer """ - periodCount: int = attr.ib(validator=AdvancedCommerceValidationUtils.period_count_validator) + periodCount: int = attr.ib(validator=HelperValidationUtils.period_count_validator) """ The number of periods the offer is active. """ 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/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/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/AdvancedCommerceRequestRefundRequest.py b/appstoreserverlibrary/models/AdvancedCommerceRequestRefundRequest.py index 6236d38b..d2fb55cc 100644 --- a/appstoreserverlibrary/models/AdvancedCommerceRequestRefundRequest.py +++ b/appstoreserverlibrary/models/AdvancedCommerceRequestRefundRequest.py @@ -7,7 +7,7 @@ from .AdvancedCommerceRequest import AdvancedCommerceRequest from .AdvancedCommerceRequestRefundItem import AdvancedCommerceRequestRefundItem -from .AdvancedCommerceValidationUtils import AdvancedCommerceValidationUtils +from .HelperValidationUtils import HelperValidationUtils @define class AdvancedCommerceRequestRefundRequest(AdvancedCommerceRequest): @@ -17,7 +17,7 @@ class AdvancedCommerceRequestRefundRequest(AdvancedCommerceRequest): https://developer.apple.com/documentation/advancedcommerceapi/requestrefundrequest """ - items: List[AdvancedCommerceRequestRefundItem] = attr.ib(validator=AdvancedCommerceValidationUtils.items_validator) + items: List[AdvancedCommerceRequestRefundItem] = attr.ib(validator=HelperValidationUtils.items_validator) """ https://developer.apple.com/documentation/advancedcommerceapi/requestrefunditem """ diff --git a/appstoreserverlibrary/models/AdvancedCommerceSubscriptionChangeMetadataDescriptors.py b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionChangeMetadataDescriptors.py index 1f960d4d..99e0c782 100644 --- a/appstoreserverlibrary/models/AdvancedCommerceSubscriptionChangeMetadataDescriptors.py +++ b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionChangeMetadataDescriptors.py @@ -5,7 +5,7 @@ import attr from .AdvancedCommerceEffective import AdvancedCommerceEffective -from .AdvancedCommerceValidationUtils import AdvancedCommerceValidationUtils +from .HelperValidationUtils import HelperValidationUtils @define class AdvancedCommerceSubscriptionChangeMetadataDescriptors(): @@ -29,7 +29,7 @@ class AdvancedCommerceSubscriptionChangeMetadataDescriptors(): description: Optional[str] = attr.ib( default=None, - validator=attr.validators.optional(AdvancedCommerceValidationUtils.description_validator) + validator=attr.validators.optional(HelperValidationUtils.description_validator) ) """ The new description for the subscription. @@ -39,7 +39,7 @@ class AdvancedCommerceSubscriptionChangeMetadataDescriptors(): displayName: Optional[str] = attr.ib( default=None, - validator=attr.validators.optional(AdvancedCommerceValidationUtils.display_name_validator) + validator=attr.validators.optional(HelperValidationUtils.display_name_validator) ) """ The new display name for the subscription. diff --git a/appstoreserverlibrary/models/AdvancedCommerceSubscriptionChangeMetadataItem.py b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionChangeMetadataItem.py index 2c5a829b..5d7e83d6 100644 --- a/appstoreserverlibrary/models/AdvancedCommerceSubscriptionChangeMetadataItem.py +++ b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionChangeMetadataItem.py @@ -5,7 +5,7 @@ import attr from .AdvancedCommerceEffective import AdvancedCommerceEffective -from .AdvancedCommerceValidationUtils import AdvancedCommerceValidationUtils +from .HelperValidationUtils import HelperValidationUtils @define class AdvancedCommerceSubscriptionChangeMetadataItem(): @@ -15,7 +15,7 @@ class AdvancedCommerceSubscriptionChangeMetadataItem(): https://developer.apple.com/documentation/advancedcommerceapi/subscriptionchangemetadataitem """ - currentSKU: str = attr.ib(validator=AdvancedCommerceValidationUtils.sku_validator) + currentSKU: str = attr.ib(validator=HelperValidationUtils.sku_validator) """ The original SKU of the item. """ @@ -32,21 +32,21 @@ class AdvancedCommerceSubscriptionChangeMetadataItem(): See effective """ - description: Optional[str] = attr.ib(default=None, validator=attr.validators.optional(AdvancedCommerceValidationUtils.description_validator)) + 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(AdvancedCommerceValidationUtils.display_name_validator)) + 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(AdvancedCommerceValidationUtils.sku_validator)) + SKU: Optional[str] = attr.ib(default=None, validator=attr.validators.optional(HelperValidationUtils.sku_validator)) """ The new SKU of the item. diff --git a/appstoreserverlibrary/models/AdvancedCommerceSubscriptionChangeMetadataRequest.py b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionChangeMetadataRequest.py index 19d8c1dc..69fb57fc 100644 --- a/appstoreserverlibrary/models/AdvancedCommerceSubscriptionChangeMetadataRequest.py +++ b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionChangeMetadataRequest.py @@ -8,7 +8,7 @@ from .AdvancedCommerceRequest import AdvancedCommerceRequest from .AdvancedCommerceSubscriptionChangeMetadataDescriptors import AdvancedCommerceSubscriptionChangeMetadataDescriptors from .AdvancedCommerceSubscriptionChangeMetadataItem import AdvancedCommerceSubscriptionChangeMetadataItem -from .AdvancedCommerceValidationUtils import AdvancedCommerceValidationUtils +from .HelperValidationUtils import HelperValidationUtils @define class AdvancedCommerceSubscriptionChangeMetadataRequest(AdvancedCommerceRequest): @@ -23,7 +23,7 @@ class AdvancedCommerceSubscriptionChangeMetadataRequest(AdvancedCommerceRequest) https://developer.apple.com/documentation/advancedcommerceapi/subscriptionchangemetadatadescriptors """ - items: Optional[List[AdvancedCommerceSubscriptionChangeMetadataItem]] = attr.ib(default=None, validator=attr.validators.optional(AdvancedCommerceValidationUtils.items_validator)) + items: Optional[List[AdvancedCommerceSubscriptionChangeMetadataItem]] = attr.ib(default=None, validator=attr.validators.optional(HelperValidationUtils.items_validator)) """ https://developer.apple.com/documentation/advancedcommerceapi/subscriptionchangemetadataitem """ diff --git a/appstoreserverlibrary/models/AdvancedCommerceSubscriptionMigrateRequest.py b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionMigrateRequest.py index aafb3622..da54738c 100644 --- a/appstoreserverlibrary/models/AdvancedCommerceSubscriptionMigrateRequest.py +++ b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionMigrateRequest.py @@ -4,7 +4,7 @@ import attr from attr import define from .AdvancedCommerceRequest import AdvancedCommerceRequest -from .AdvancedCommerceValidationUtils import AdvancedCommerceValidationUtils +from .HelperValidationUtils import HelperValidationUtils from .AdvancedCommerceSubscriptionMigrateDescriptors import AdvancedCommerceSubscriptionMigrateDescriptors from .AdvancedCommerceSubscriptionMigrateItem import AdvancedCommerceSubscriptionMigrateItem from .AdvancedCommerceSubscriptionMigrateRenewalItem import AdvancedCommerceSubscriptionMigrateRenewalItem @@ -22,7 +22,7 @@ class AdvancedCommerceSubscriptionMigrateRequest(AdvancedCommerceRequest): https://developer.apple.com/documentation/advancedcommerceapi/subscriptionmigratedescriptors """ - items: List[AdvancedCommerceSubscriptionMigrateItem] = attr.ib(validator=AdvancedCommerceValidationUtils.items_validator) + 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. @@ -41,7 +41,7 @@ class AdvancedCommerceSubscriptionMigrateRequest(AdvancedCommerceRequest): https://developer.apple.com/documentation/advancedcommerceapi/taxcode """ - renewalItems: Optional[List[AdvancedCommerceSubscriptionMigrateRenewalItem]] = attr.ib(default=None, validator=attr.validators.optional(AdvancedCommerceValidationUtils.items_validator)) + 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. diff --git a/appstoreserverlibrary/models/AdvancedCommerceSubscriptionModifyChangeItem.py b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionModifyChangeItem.py index dbc5228e..532ce30b 100644 --- a/appstoreserverlibrary/models/AdvancedCommerceSubscriptionModifyChangeItem.py +++ b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionModifyChangeItem.py @@ -7,7 +7,7 @@ from .AdvancedCommerceEffective import AdvancedCommerceEffective from .AdvancedCommerceOffer import AdvancedCommerceOffer from .AdvancedCommerceReason import AdvancedCommerceReason -from .AdvancedCommerceValidationUtils import AdvancedCommerceValidationUtils +from .HelperValidationUtils import HelperValidationUtils @define @@ -18,7 +18,7 @@ class AdvancedCommerceSubscriptionModifyChangeItem(AbstractAdvancedCommerceItem) https://developer.apple.com/documentation/advancedcommerceapi/subscriptionmodifychangeitem """ - currentSKU: str = attr.ib(validator=AdvancedCommerceValidationUtils.sku_validator) + currentSKU: str = attr.ib(validator=HelperValidationUtils.sku_validator) """ https://developer.apple.com/documentation/advancedcommerceapi/sku """ diff --git a/appstoreserverlibrary/models/AdvancedCommerceSubscriptionModifyDescriptors.py b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionModifyDescriptors.py index d44d4990..b46edde0 100644 --- a/appstoreserverlibrary/models/AdvancedCommerceSubscriptionModifyDescriptors.py +++ b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionModifyDescriptors.py @@ -4,7 +4,7 @@ import attr from attr import define from .AdvancedCommerceEffective import AdvancedCommerceEffective -from .AdvancedCommerceValidationUtils import AdvancedCommerceValidationUtils +from .HelperValidationUtils import HelperValidationUtils @define class AdvancedCommerceSubscriptionModifyDescriptors(): @@ -24,12 +24,12 @@ class AdvancedCommerceSubscriptionModifyDescriptors(): See effective """ - description: Optional[str] = attr.ib(default=None, validator=attr.validators.optional(AdvancedCommerceValidationUtils.description_validator)) + 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(AdvancedCommerceValidationUtils.display_name_validator) + displayName: Optional[str] = attr.ib(default=None, validator=attr.validators.optional(HelperValidationUtils.display_name_validator) ) """ https://developer.apple.com/documentation/advancedcommerceapi/displayname diff --git a/appstoreserverlibrary/models/AdvancedCommerceSubscriptionModifyInAppRequest.py b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionModifyInAppRequest.py index bec1d218..e1ac2611 100644 --- a/appstoreserverlibrary/models/AdvancedCommerceSubscriptionModifyInAppRequest.py +++ b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionModifyInAppRequest.py @@ -4,7 +4,7 @@ import attr from attr import define from .AbstractAdvancedCommerceInAppRequest import AbstractAdvancedCommerceInAppRequest -from .AdvancedCommerceValidationUtils import AdvancedCommerceValidationUtils +from .HelperValidationUtils import HelperValidationUtils from .AdvancedCommerceSubscriptionModifyAddItem import AdvancedCommerceSubscriptionModifyAddItem from .AdvancedCommerceSubscriptionModifyChangeItem import AdvancedCommerceSubscriptionModifyChangeItem from .AdvancedCommerceSubscriptionModifyDescriptors import AdvancedCommerceSubscriptionModifyDescriptors @@ -34,12 +34,12 @@ class AdvancedCommerceSubscriptionModifyInAppRequest(AbstractAdvancedCommerceInA https://developer.apple.com/documentation/advancedcommerceapi/retainbillingcycle """ - addItems: Optional[List[AdvancedCommerceSubscriptionModifyAddItem]] = attr.ib(default=None, validator=attr.validators.optional(AdvancedCommerceValidationUtils.items_validator)) + 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(AdvancedCommerceValidationUtils.items_validator)) + changeItems: Optional[List[AdvancedCommerceSubscriptionModifyChangeItem]] = attr.ib(default=None, validator=attr.validators.optional(HelperValidationUtils.items_validator)) """ https://developer.apple.com/documentation/advancedcommerceapi/subscriptionmodifychangeitem """ diff --git a/appstoreserverlibrary/models/AdvancedCommerceSubscriptionPriceChangeItem.py b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionPriceChangeItem.py index cc2212e2..77889374 100644 --- a/appstoreserverlibrary/models/AdvancedCommerceSubscriptionPriceChangeItem.py +++ b/appstoreserverlibrary/models/AdvancedCommerceSubscriptionPriceChangeItem.py @@ -4,7 +4,7 @@ from attr import define from typing import List, Optional from .AbstractAdvancedCommerceBaseItem import AbstractAdvancedCommerceBaseItem -from .AdvancedCommerceValidationUtils import AdvancedCommerceValidationUtils +from .HelperValidationUtils import HelperValidationUtils @define class AdvancedCommerceSubscriptionPriceChangeItem(AbstractAdvancedCommerceBaseItem): @@ -19,7 +19,7 @@ class AdvancedCommerceSubscriptionPriceChangeItem(AbstractAdvancedCommerceBaseIt https://developer.apple.com/documentation/advancedcommerceapi/price """ - dependentSKUs: Optional[List[str]] = attr.ib(default=None, validator=attr.validators.optional(AdvancedCommerceValidationUtils.dependent_skus_validator)) + 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/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 index b8b4dd6f..d2c67c68 100644 --- a/appstoreserverlibrary/models/AlternateProduct.py +++ b/appstoreserverlibrary/models/AlternateProduct.py @@ -5,8 +5,11 @@ from attr import define import attr +from .BillingPlanType import BillingPlanType +from .LibraryUtility import AttrsRawValueAware + @define -class AlternateProduct: +class AlternateProduct(AttrsRawValueAware): """ A switch-plan message and product ID you provide in a real-time response to your Get Retention Message endpoint. @@ -26,3 +29,13 @@ class AlternateProduct: 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/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/AdvancedCommerceValidationUtils.py b/appstoreserverlibrary/models/HelperValidationUtils.py similarity index 72% rename from appstoreserverlibrary/models/AdvancedCommerceValidationUtils.py rename to appstoreserverlibrary/models/HelperValidationUtils.py index 5b1ea638..3469fbad 100644 --- a/appstoreserverlibrary/models/AdvancedCommerceValidationUtils.py +++ b/appstoreserverlibrary/models/HelperValidationUtils.py @@ -5,7 +5,7 @@ T = TypeVar('T') -class AdvancedCommerceValidationUtils: +class HelperValidationUtils: MAXIMUM_DESCRIPTION_LENGTH = 45 MAXIMUM_DISPLAY_NAME_LENGTH = 30 MAXIMUM_SKU_LENGTH = 128 @@ -20,10 +20,10 @@ def description_validator(instance, attribute, value): Raises: ValueError: If description exceeds maximum length """ - if len(value) > AdvancedCommerceValidationUtils.MAXIMUM_DESCRIPTION_LENGTH: + if len(value) > HelperValidationUtils.MAXIMUM_DESCRIPTION_LENGTH: raise ValueError( f"Description length cannot exceed " - f"{AdvancedCommerceValidationUtils.MAXIMUM_DESCRIPTION_LENGTH} characters" + f"{HelperValidationUtils.MAXIMUM_DESCRIPTION_LENGTH} characters" ) @staticmethod @@ -34,10 +34,10 @@ def display_name_validator(instance, attribute, value): Raises: ValueError: If display name exceeds maximum length """ - if len(value) > AdvancedCommerceValidationUtils.MAXIMUM_DISPLAY_NAME_LENGTH: + if len(value) > HelperValidationUtils.MAXIMUM_DISPLAY_NAME_LENGTH: raise ValueError( f"Display name length cannot exceed " - f"{AdvancedCommerceValidationUtils.MAXIMUM_DISPLAY_NAME_LENGTH} characters" + f"{HelperValidationUtils.MAXIMUM_DISPLAY_NAME_LENGTH} characters" ) @staticmethod @@ -48,10 +48,10 @@ def sku_validator(instance, attribute, value): Raises: ValueError: If SKU exceeds maximum length """ - if len(value) > AdvancedCommerceValidationUtils.MAXIMUM_SKU_LENGTH: + if len(value) > HelperValidationUtils.MAXIMUM_SKU_LENGTH: raise ValueError( f"SKU length cannot exceed " - f"{AdvancedCommerceValidationUtils.MAXIMUM_SKU_LENGTH} characters" + f"{HelperValidationUtils.MAXIMUM_SKU_LENGTH} characters" ) @staticmethod @@ -62,12 +62,12 @@ def period_count_validator(instance, attribute, value): Raises: ValueError: If period_count is out of range """ - if (value < AdvancedCommerceValidationUtils.MIN_PERIOD or - value > AdvancedCommerceValidationUtils.MAX_PERIOD): + if (value < HelperValidationUtils.MIN_PERIOD or + value > HelperValidationUtils.MAX_PERIOD): raise ValueError( f"Period count must be between " - f"{AdvancedCommerceValidationUtils.MIN_PERIOD} and " - f"{AdvancedCommerceValidationUtils.MAX_PERIOD}" + f"{HelperValidationUtils.MIN_PERIOD} and " + f"{HelperValidationUtils.MAX_PERIOD}" ) @staticmethod @@ -94,8 +94,8 @@ def dependent_skus_validator(instance, attribute, value): ValueError: If any SKU exceeds maximum length """ for sku in value: - if len(sku) > AdvancedCommerceValidationUtils.MAXIMUM_SKU_LENGTH: + if len(sku) > HelperValidationUtils.MAXIMUM_SKU_LENGTH: raise ValueError( f"SKU length cannot exceed " - f"{AdvancedCommerceValidationUtils.MAXIMUM_SKU_LENGTH} characters" + f"{HelperValidationUtils.MAXIMUM_SKU_LENGTH} characters" ) diff --git a/appstoreserverlibrary/models/JWSRenewalInfoDecodedPayload.py b/appstoreserverlibrary/models/JWSRenewalInfoDecodedPayload.py index 5d537022..a9215bab 100644 --- a/appstoreserverlibrary/models/JWSRenewalInfoDecodedPayload.py +++ b/appstoreserverlibrary/models/JWSRenewalInfoDecodedPayload.py @@ -11,6 +11,9 @@ 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): @@ -193,6 +196,28 @@ class JWSRenewalInfoDecodedPayload(AttrsRawValueAware): 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 a9cb3dbf..6ccc3e58 100644 --- a/appstoreserverlibrary/models/JWSTransactionDecodedPayload.py +++ b/appstoreserverlibrary/models/JWSTransactionDecodedPayload.py @@ -12,6 +12,9 @@ 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 @@ -271,3 +274,25 @@ class JWSTransactionDecodedPayload(AttrsRawValueAware): 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/NotificationTypeV2.py b/appstoreserverlibrary/models/NotificationTypeV2.py index 78266768..e4a80b5e 100644 --- a/appstoreserverlibrary/models/NotificationTypeV2.py +++ b/appstoreserverlibrary/models/NotificationTypeV2.py @@ -30,3 +30,6 @@ class NotificationTypeV2(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): 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/RealtimeResponseBody.py b/appstoreserverlibrary/models/RealtimeResponseBody.py index 0ebc785d..15420e20 100644 --- a/appstoreserverlibrary/models/RealtimeResponseBody.py +++ b/appstoreserverlibrary/models/RealtimeResponseBody.py @@ -8,6 +8,7 @@ from .Message import Message from .AlternateProduct import AlternateProduct from .PromotionalOffer import PromotionalOffer +from .AdvancedCommerceInfo import AdvancedCommerceInfo @define class RealtimeResponseBody: @@ -37,3 +38,10 @@ class RealtimeResponseBody: 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/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/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/tests/resources/models/signedRenewalInfo.json b/tests/resources/models/signedRenewalInfo.json index d4bf60be..1cbce07b 100644 --- a/tests/resources/models/signedRenewalInfo.json +++ b/tests/resources/models/signedRenewalInfo.json @@ -22,5 +22,39 @@ ], "appTransactionId": "71134", "offerPeriod": "P1Y", - "appAccountToken": "7e3fb20b-4cdb-47cc-936d-99d65f608138" + "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/signedTransaction.json b/tests/resources/models/signedTransaction.json index f81bac8c..3d405b64 100644 --- a/tests/resources/models/signedTransaction.json +++ b/tests/resources/models/signedTransaction.json @@ -26,5 +26,41 @@ "currency": "USD", "offerDiscountType": "PAY_AS_YOU_GO", "appTransactionId": "71134", - "offerPeriod": "P1Y" + "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/test_advanced_commerce_models.py b/tests/test_advanced_commerce_models.py index 660bf7f0..88ec27cd 100644 --- a/tests/test_advanced_commerce_models.py +++ b/tests/test_advanced_commerce_models.py @@ -11,9 +11,14 @@ 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 @@ -44,7 +49,14 @@ from appstoreserverlibrary.models.AdvancedCommerceSubscriptionReactivateItem import AdvancedCommerceSubscriptionReactivateItem from appstoreserverlibrary.models.AdvancedCommerceSubscriptionRevokeRequest import AdvancedCommerceSubscriptionRevokeRequest from appstoreserverlibrary.models.AdvancedCommerceSubscriptionRevokeResponse import AdvancedCommerceSubscriptionRevokeResponse -from appstoreserverlibrary.models.AdvancedCommerceValidationUtils import AdvancedCommerceValidationUtils +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 @@ -143,47 +155,47 @@ def test_advanced_commerce_effective(self): def test_validation_utils_description(self): valid_description = "Valid description" - AdvancedCommerceValidationUtils.description_validator(None, None, valid_description) + HelperValidationUtils.description_validator(None, None, valid_description) max_length_description = "A" * 45 - AdvancedCommerceValidationUtils.description_validator(None, None, max_length_description) + HelperValidationUtils.description_validator(None, None, max_length_description) too_long_description = "A" * 46 with self.assertRaises(ValueError): - AdvancedCommerceValidationUtils.description_validator(None, None, too_long_description) + HelperValidationUtils.description_validator(None, None, too_long_description) def test_validation_utils_display_name(self): valid_display_name = "Valid Name" - AdvancedCommerceValidationUtils.display_name_validator(None, None, valid_display_name) + HelperValidationUtils.display_name_validator(None, None, valid_display_name) max_length_display_name = "A" * 30 - AdvancedCommerceValidationUtils.display_name_validator(None, None, max_length_display_name) + HelperValidationUtils.display_name_validator(None, None, max_length_display_name) too_long_display_name = "A" * 31 with self.assertRaises(ValueError): - AdvancedCommerceValidationUtils.display_name_validator(None, None, too_long_display_name) + HelperValidationUtils.display_name_validator(None, None, too_long_display_name) def test_validation_utils_sku(self): valid_sku = "valid.sku.123" - AdvancedCommerceValidationUtils.sku_validator(None, None, valid_sku) + HelperValidationUtils.sku_validator(None, None, valid_sku) max_length_sku = "A" * 128 - AdvancedCommerceValidationUtils.sku_validator(None, None, max_length_sku) + HelperValidationUtils.sku_validator(None, None, max_length_sku) too_long_sku = "A" * 129 with self.assertRaises(ValueError): - AdvancedCommerceValidationUtils.sku_validator(None, None, too_long_sku) + HelperValidationUtils.sku_validator(None, None, too_long_sku) def test_validation_utils_period_count(self): - AdvancedCommerceValidationUtils.period_count_validator(None, None, 1) - AdvancedCommerceValidationUtils.period_count_validator(None, None, 6) - AdvancedCommerceValidationUtils.period_count_validator(None, None, 12) + 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): - AdvancedCommerceValidationUtils.period_count_validator(None, None, 0) + HelperValidationUtils.period_count_validator(None, None, 0) with self.assertRaises(ValueError): - AdvancedCommerceValidationUtils.period_count_validator(None, None, 13) + HelperValidationUtils.period_count_validator(None, None, 13) def test_validation_utils_items(self): valid_list = [ @@ -194,17 +206,17 @@ def test_validation_utils_items(self): price=1000 ) ] - AdvancedCommerceValidationUtils.items_validator(None, None, valid_list) + HelperValidationUtils.items_validator(None, None, valid_list) with self.assertRaises(ValueError): - AdvancedCommerceValidationUtils.items_validator(None, None, None) + HelperValidationUtils.items_validator(None, None, None) with self.assertRaises(ValueError): - AdvancedCommerceValidationUtils.items_validator(None, None, []) + HelperValidationUtils.items_validator(None, None, []) list_with_none = [None] with self.assertRaises(ValueError): - AdvancedCommerceValidationUtils.items_validator(None, None, list_with_none) + 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') @@ -609,3 +621,52 @@ def test_advanced_commerce_subscription_migrate_response_deserialization(self): self.assertEqual("signed_renewal_info_value", response.signedRenewalInfo) self.assertEqual("signed_transaction_info_value", response.signedTransactionInfo) + + 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_decoded_payloads.py b/tests/test_decoded_payloads.py index f5644613..20232480 100644 --- a/tests/test_decoded_payloads.py +++ b/tests/test_decoded_payloads.py @@ -4,6 +4,7 @@ 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 @@ -13,12 +14,17 @@ 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, get_signed_data_verifier @@ -87,6 +93,43 @@ def test_transaction_decoding(self): 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') @@ -170,6 +213,39 @@ def test_renewal_info_decoding(self): 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_notification_decoding(self): signed_notification = create_signed_data_from_json('tests/resources/models/signedNotification.json') diff --git a/tests/test_retention_messaging.py b/tests/test_retention_messaging.py index bfd42ae2..45b66f67 100644 --- a/tests/test_retention_messaging.py +++ b/tests/test_retention_messaging.py @@ -4,6 +4,8 @@ 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 @@ -44,7 +46,7 @@ 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) + alternate_product = AlternateProduct(messageIdentifier=message_id, productId=product_id, billingPlanType=BillingPlanType.MONTHLY) response_body = RealtimeResponseBody(alternateProduct=alternate_product) # Serialize to dict @@ -57,6 +59,7 @@ def test_realtime_response_body_with_alternate_product(self): 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) @@ -68,6 +71,8 @@ def test_realtime_response_body_with_alternate_product(self): 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): @@ -169,3 +174,28 @@ def test_realtime_response_body_with_promotional_offer_v1(self): 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) From 4ab20fa3fe4195f1a54be487a618ef539527593b Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Tue, 5 May 2026 12:45:41 -0700 Subject: [PATCH 093/101] Updating API URLs --- appstoreserverlibrary/api_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/appstoreserverlibrary/api_client.py b/appstoreserverlibrary/api_client.py index 6374a552..e9dcb6d7 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -696,11 +696,11 @@ def __init__(self, signing_key: bytes, key_id: str, issuer_id: str, bundle_id: s 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.itunes.apple.com" + self._base_url = "https://api.storekit-sandbox.apple.com" else: raise ValueError("Invalid environment provided") self._signing_key = serialization.load_pem_private_key(signing_key, password=None, backend=default_backend()) From 374fb8400af75f311debc6e3fa57688b7f9b3881 Mon Sep 17 00:00:00 2001 From: Riyaz Panjwani Date: Tue, 5 May 2026 15:48:16 -0700 Subject: [PATCH 094/101] Update finish transaction and anyTransactionId --- appstoreserverlibrary/api_client.py | 88 ++++++++++++++++++----------- tests/test_api_client.py | 8 +++ tests/test_api_client_async.py | 8 +++ 3 files changed, 70 insertions(+), 34 deletions(-) diff --git a/appstoreserverlibrary/api_client.py b/appstoreserverlibrary/api_client.py index e9dcb6d7..b96ab088 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -800,12 +800,12 @@ def extend_subscription_renewal_date(self, original_transaction_id: str, extend_ """ 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: Optional[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 @@ -813,15 +813,15 @@ def get_all_subscription_statuses(self, transaction_id: str, status: Optional[Li queryParameters: Dict[str, List[str]] = dict() if status is not None: queryParameters["status"] = [s.value for s in status] - - return self._make_request(f"/inApps/v1/subscriptions/{transaction_id}", "GET", queryParameters, None, StatusResponse, None) + + 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: Optional[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 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 @@ -830,8 +830,8 @@ def get_refund_history(self, transaction_id: str, revision: Optional[str]) -> Re queryParameters: Dict[str, List[str]] = dict() if revision is not None: queryParameters["revision"] = [revision] - - return self._make_request(f"/inApps/v2/refund/lookup/{transaction_id}", "GET", queryParameters, None, RefundHistoryResponse, None) + + 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: """ @@ -872,12 +872,12 @@ def get_notification_history(self, pagination_token: Optional[str], notification 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, version: GetTransactionHistoryVersion = GetTransactionHistoryVersion.V1) -> 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 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. @@ -912,8 +912,8 @@ def get_transaction_history(self, transaction_id: str, revision: Optional[str], if transaction_history_request.revoked is not None: queryParameters["revoked"] = [str(transaction_history_request.revoked)] - return self._make_request("/inApps/{}/history/{}".format(version.value, transaction_id), "GET", queryParameters, None, HistoryResponse, None) - + 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. @@ -1135,16 +1135,26 @@ def get_performance_test_results(self, request_id: str) -> PerformanceTestResult """ return self._make_request(f"/inApps/v1/messaging/performanceTest/result/{request_id}", "GET", {}, None, PerformanceTestResultResponse, None) - def get_app_transaction_info(self, transaction_id: str) -> AppTransactionInfoResponse: + def get_app_transaction_info(self, any_transaction_id: str) -> AppTransactionInfoResponse: """ Get a customer's app transaction information for your app. - - :param transaction_id Any originalTransactionId, transactionId or appTransactionId that belongs to the customer 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/{transaction_id}", "GET", {}, None, AppTransactionInfoResponse, None) + 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): @@ -1200,12 +1210,12 @@ async def extend_subscription_renewal_date(self, original_transaction_id: str, e """ 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, transaction_id: str, status: Optional[List[Status]] = None) -> StatusResponse: + 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 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 @@ -1213,15 +1223,15 @@ async def get_all_subscription_statuses(self, transaction_id: str, status: Optio 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/{transaction_id}", "GET", queryParameters, None, StatusResponse, None) + + return await self._make_request(f"/inApps/v1/subscriptions/{any_transaction_id}", "GET", queryParameters, None, StatusResponse, None) - async def get_refund_history(self, transaction_id: str, revision: Optional[str]) -> RefundHistoryResponse: + 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 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 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 @@ -1230,8 +1240,8 @@ async def get_refund_history(self, transaction_id: str, revision: Optional[str]) queryParameters: Dict[str, List[str]] = dict() if revision is not None: queryParameters["revision"] = [revision] - - return await self._make_request(f"/inApps/v2/refund/lookup/{transaction_id}", "GET", queryParameters, None, RefundHistoryResponse, None) + + 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: """ @@ -1272,12 +1282,12 @@ async def get_notification_history(self, pagination_token: Optional[str], notifi return await self._make_request("/inApps/v1/notifications/history", "POST", queryParameters, notification_history_request, NotificationHistoryResponse, None) - async def get_transaction_history(self, transaction_id: str, revision: Optional[str], transaction_history_request: TransactionHistoryRequest, version: GetTransactionHistoryVersion = GetTransactionHistoryVersion.V1) -> HistoryResponse: + 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 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 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. @@ -1312,8 +1322,8 @@ async def get_transaction_history(self, transaction_id: str, revision: Optional[ if transaction_history_request.revoked is not None: queryParameters["revoked"] = [str(transaction_history_request.revoked)] - return await self._make_request("/inApps/" + version + "/history/" + transaction_id, "GET", queryParameters, None, HistoryResponse, None) - + 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. @@ -1534,14 +1544,24 @@ async def get_performance_test_results(self, request_id: str) -> PerformanceTest """ return await self._make_request(f"/inApps/v1/messaging/performanceTest/result/{request_id}", "GET", {}, None, PerformanceTestResultResponse, None) - async def get_app_transaction_info(self, transaction_id: str) -> AppTransactionInfoResponse: + async def get_app_transaction_info(self, any_transaction_id: str) -> AppTransactionInfoResponse: """ Get a customer's app transaction information for your app. - - :param transaction_id Any originalTransactionId, transactionId or appTransactionId that belongs to the customer 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/{transaction_id}", "GET", {}, None, AppTransactionInfoResponse, None) + 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/tests/test_api_client.py b/tests/test_api_client.py index c5cb4212..f3f9061e 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -858,6 +858,14 @@ def test_get_app_transaction_info_transaction_id_not_found(self): 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') diff --git a/tests/test_api_client_async.py b/tests/test_api_client_async.py index 27101255..8b837261 100644 --- a/tests/test_api_client_async.py +++ b/tests/test_api_client_async.py @@ -863,6 +863,14 @@ async def test_get_app_transaction_info_transaction_id_not_found(self): 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') From ea86f14328bbceba58a7efc47617747b5c578767 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Tue, 5 May 2026 17:00:11 -0700 Subject: [PATCH 095/101] Releasing v3.1.0 --- CHANGELOG.md | 6 ++++++ appstoreserverlibrary/api_client.py | 2 +- pyproject.toml | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d046f665..5fd6eac7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Version 3.1.0 +- Incoproate 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 diff --git a/appstoreserverlibrary/api_client.py b/appstoreserverlibrary/api_client.py index e9dcb6d7..28893815 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -727,7 +727,7 @@ def _get_full_url(self, path) -> str: def _get_headers(self) -> Dict[str, str]: return { - 'User-Agent': "app-store-server-library/python/3.0.0", + 'User-Agent': "app-store-server-library/python/3.1.0", 'Authorization': f'Bearer {self._generate_token()}', 'Accept': 'application/json' } diff --git a/pyproject.toml b/pyproject.toml index e9c5a0e8..359ab3b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" [project] name = "app-store-server-library" -version = "3.0.0" +version = "3.1.0" description = "The App Store Server Library" readme = {file = "README.md", content-type = "text/markdown"} license = {text = "MIT"} From f464445714acfa88a99f509ff1f5acb03c91d0e3 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Tue, 5 May 2026 17:00:11 -0700 Subject: [PATCH 096/101] Correcting typo in CHANGELOG --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fd6eac7..b1239f41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # Changelog ## Version 3.1.0 -- Incoproate 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 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 From ab5ce5ffa1226044b56a01de3e410562cd3dd903 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Wed, 6 May 2026 19:52:02 -0700 Subject: [PATCH 097/101] Updating to fix bug with serialization of version and type for new objects --- .../models/LibraryUtility.py | 2 + ...cedCommerceOneTimeChargeCreateRequest.json | 4 +- tests/test_advanced_commerce_models.py | 40 +++++++++++++++++++ 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/appstoreserverlibrary/models/LibraryUtility.py b/appstoreserverlibrary/models/LibraryUtility.py index 253d446f..0ee535d8 100644 --- a/appstoreserverlibrary/models/LibraryUtility.py +++ b/appstoreserverlibrary/models/LibraryUtility.py @@ -97,6 +97,8 @@ def make_overrides(cl): 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 diff --git a/tests/resources/models/advancedCommerceOneTimeChargeCreateRequest.json b/tests/resources/models/advancedCommerceOneTimeChargeCreateRequest.json index d9874797..b81f2783 100644 --- a/tests/resources/models/advancedCommerceOneTimeChargeCreateRequest.json +++ b/tests/resources/models/advancedCommerceOneTimeChargeCreateRequest.json @@ -10,7 +10,5 @@ "requestReferenceId": "550e8400-e29b-41d4-a716-446655440000" }, "taxCode": "taxCode", - "storefront": "USA", - "operation": "CREATE", - "version": "1.0" + "storefront": "USA" } diff --git a/tests/test_advanced_commerce_models.py b/tests/test_advanced_commerce_models.py index 88ec27cd..93e2315d 100644 --- a/tests/test_advanced_commerce_models.py +++ b/tests/test_advanced_commerce_models.py @@ -622,6 +622,46 @@ def test_advanced_commerce_subscription_migrate_response_deserialization(self): 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) From baf6674af8350175e31d564bce1c8e1cf96659a2 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Wed, 6 May 2026 22:25:19 -0700 Subject: [PATCH 098/101] Release v3.1.1 --- CHANGELOG.md | 3 +++ appstoreserverlibrary/api_client.py | 2 +- pyproject.toml | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1239f41..fd0f86ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 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 diff --git a/appstoreserverlibrary/api_client.py b/appstoreserverlibrary/api_client.py index a3fa52f1..3441041c 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -727,7 +727,7 @@ def _get_full_url(self, path) -> str: def _get_headers(self) -> Dict[str, str]: return { - 'User-Agent': "app-store-server-library/python/3.1.0", + 'User-Agent': "app-store-server-library/python/3.1.1", 'Authorization': f'Bearer {self._generate_token()}', 'Accept': 'application/json' } diff --git a/pyproject.toml b/pyproject.toml index 359ab3b1..c3b54b82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" [project] name = "app-store-server-library" -version = "3.1.0" +version = "3.1.1" description = "The App Store Server Library" readme = {file = "README.md", content-type = "text/markdown"} license = {text = "MIT"} From ede8e17cdeb1e5ec22d86ae0938d55cb83626a01 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Fri, 29 May 2026 21:19:51 -0700 Subject: [PATCH 099/101] Updating Python to support validity with SKEW checking with cryptography 43 methods --- appstoreserverlibrary/signed_data_verifier.py | 6 ++++++ pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/appstoreserverlibrary/signed_data_verifier.py b/appstoreserverlibrary/signed_data_verifier.py index f56440a7..944fee55 100644 --- a/appstoreserverlibrary/signed_data_verifier.py +++ b/appstoreserverlibrary/signed_data_verifier.py @@ -179,6 +179,7 @@ def _decode_signed_object(self, signed_obj: str) -> dict: 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 @@ -327,11 +328,16 @@ 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 diff --git a/pyproject.toml b/pyproject.toml index c3b54b82..79be72fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ dependencies = [ "attrs>=21.3.0", "PyJWT>=2.6.0,<3", "requests>=2.28.0,<3", - "cryptography>=40.0.0", + "cryptography>=43.0.0", "pyOpenSSL>=23.1.1", "asn1==3.2.0", "cattrs>=23.1.2", diff --git a/requirements.txt b/requirements.txt index 81519baf..caa730f1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ attrs >= 21.3.0 PyJWT >= 2.6.0, < 3 requests >= 2.28.0, < 3 -cryptography >= 40.0.0 +cryptography >= 43.0.0 pyOpenSSL >= 23.1.1 asn1==3.2.0 cattrs >= 23.1.2 From 54a3c977a6cf7a415c74c534da557992f930b79d Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Mon, 1 Jun 2026 11:33:21 -0700 Subject: [PATCH 100/101] Release v3.1.2 --- CHANGELOG.md | 3 +++ appstoreserverlibrary/api_client.py | 2 +- pyproject.toml | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd0f86ad..3263e304 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # 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] diff --git a/appstoreserverlibrary/api_client.py b/appstoreserverlibrary/api_client.py index 3441041c..0309ec93 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -727,7 +727,7 @@ def _get_full_url(self, path) -> str: def _get_headers(self) -> Dict[str, str]: return { - 'User-Agent': "app-store-server-library/python/3.1.1", + 'User-Agent': "app-store-server-library/python/3.1.2", 'Authorization': f'Bearer {self._generate_token()}', 'Accept': 'application/json' } diff --git a/pyproject.toml b/pyproject.toml index 79be72fd..6f7b36ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" [project] name = "app-store-server-library" -version = "3.1.1" +version = "3.1.2" description = "The App Store Server Library" readme = {file = "README.md", content-type = "text/markdown"} license = {text = "MIT"} From 413d576831c49ff073612236cfc08dd62a0a8fdd Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Fri, 12 Jun 2026 17:12:29 -0700 Subject: [PATCH 101/101] Move to commit SHAs for .github/workflows actions --- .github/workflows/ci-prb.yml | 4 ++-- .github/workflows/ci-release-docs.yml | 8 ++++---- .github/workflows/ci-release.yml | 6 +++--- .github/workflows/ci-snapshot.yml | 8 ++++---- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci-prb.yml b/.github/workflows/ci-prb.yml index 832b5428..78842084 100644 --- a/.github/workflows/ci-prb.yml +++ b/.github/workflows/ci-prb.yml @@ -16,9 +16,9 @@ jobs: os: [ ubuntu-latest ] steps: - name: Checkout Code - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Python ${{ matrix.python }} - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ matrix.python }} - name: Install dependencies diff --git a/.github/workflows/ci-release-docs.yml b/.github/workflows/ci-release-docs.yml index 332219e3..8125aa6a 100644 --- a/.github/workflows/ci-release-docs.yml +++ b/.github/workflows/ci-release-docs.yml @@ -9,9 +9,9 @@ jobs: name: Python Doc Builder runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.14" - name: Install dependencies @@ -24,7 +24,7 @@ jobs: - name: Spinx build run: sphinx-build -b html _staging _build - name: Upload docs - uses: actions/upload-pages-artifact@v5 + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: _build deploy: @@ -40,4 +40,4 @@ jobs: steps: - name: Deploy id: deployment - uses: actions/deploy-pages@v5 + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 diff --git a/.github/workflows/ci-release.yml b/.github/workflows/ci-release.yml index 649a8b36..8fe60a00 100644 --- a/.github/workflows/ci-release.yml +++ b/.github/workflows/ci-release.yml @@ -14,9 +14,9 @@ jobs: permissions: id-token: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.14" - name: Install build @@ -30,4 +30,4 @@ jobs: python3 -m build - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + 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 632c5bfc..5e7a68a2 100644 --- a/.github/workflows/ci-snapshot.yml +++ b/.github/workflows/ci-snapshot.yml @@ -11,9 +11,9 @@ jobs: name: Python Snapshot Builder runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.14" - name: Install build @@ -27,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/