From 918eee8aa2c3e9daa169062fd5570170d95fc35f Mon Sep 17 00:00:00 2001 From: Riyaz Panjwani Date: Mon, 22 Dec 2025 17:46:19 -0800 Subject: [PATCH] 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)