From 910a67641ff934d27d9c1e0514f9a09d1916a481 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Mon, 10 Jun 2024 15:49:02 -0700 Subject: [PATCH] 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 e977e28..ed54c4d 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 023892e..ccc6283 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 a1b46b1..09a1d8b 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 17c07a8..3bc565b 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 05ac3a0..84cbab4 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 b031c1a..ab6a0ae 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')