diff --git a/Sources/AppStoreServerLibrary/AppStoreServerAPIClient.swift b/Sources/AppStoreServerLibrary/AppStoreServerAPIClient.swift index db68af1f..c0710803 100644 --- a/Sources/AppStoreServerLibrary/AppStoreServerAPIClient.swift +++ b/Sources/AppStoreServerLibrary/AppStoreServerAPIClient.swift @@ -533,8 +533,14 @@ public enum APIError: Int64 { ///An error that indicates the transaction identifier doesn’t represent a consumable in-app purchase. /// ///[InvalidTransactionNotConsumableError](https://developer.apple.com/documentation/appstoreserverapi/invalidtransactionnotconsumableerror) + @available(*, deprecated) case invalidTransactionNotConsumable = 4000043 + ///An error that indicates the transaction identifier represents an unsupported in-app purchase type. + /// + ///[InvalidTransactionTypeNotSupportedError](https://developer.apple.com/documentation/appstoreserverapi/invalidtransactiontypenotsupportederror) + case invalidTransactionTypeNotSupported = 4000047 + ///An error that indicates the subscription doesn't qualify for a renewal-date extension due to its subscription state. /// ///[SubscriptionExtensionIneligibleError](https://developer.apple.com/documentation/appstoreserverapi/subscriptionextensionineligibleerror) diff --git a/Sources/AppStoreServerLibrary/Models/ConsumptionRequest.swift b/Sources/AppStoreServerLibrary/Models/ConsumptionRequest.swift index ab81fceb..264f9ed3 100644 --- a/Sources/AppStoreServerLibrary/Models/ConsumptionRequest.swift +++ b/Sources/AppStoreServerLibrary/Models/ConsumptionRequest.swift @@ -7,7 +7,7 @@ import Foundation ///[ConsumptionRequest](https://developer.apple.com/documentation/appstoreserverapi/consumptionrequest) public struct ConsumptionRequest: Decodable, Encodable, Hashable { - public init(customerConsented: Bool? = nil, consumptionStatus: ConsumptionStatus? = nil, platform: Platform? = nil, sampleContentProvided: Bool? = nil, deliveryStatus: DeliveryStatus? = nil, appAccountToken: UUID? = nil, accountTenure: AccountTenure? = nil, playTime: PlayTime? = nil, lifetimeDollarsRefunded: LifetimeDollarsRefunded? = nil, lifetimeDollarsPurchased: LifetimeDollarsPurchased? = nil, userStatus: UserStatus? = nil) { + public init(customerConsented: Bool? = nil, consumptionStatus: ConsumptionStatus? = nil, platform: Platform? = nil, sampleContentProvided: Bool? = nil, deliveryStatus: DeliveryStatus? = nil, appAccountToken: UUID? = nil, accountTenure: AccountTenure? = nil, playTime: PlayTime? = nil, lifetimeDollarsRefunded: LifetimeDollarsRefunded? = nil, lifetimeDollarsPurchased: LifetimeDollarsPurchased? = nil, userStatus: UserStatus? = nil, refundPreference: RefundPreference? = nil) { self.customerConsented = customerConsented self.consumptionStatus = consumptionStatus self.platform = platform @@ -19,9 +19,10 @@ public struct ConsumptionRequest: Decodable, Encodable, Hashable { self.lifetimeDollarsRefunded = lifetimeDollarsRefunded self.lifetimeDollarsPurchased = lifetimeDollarsPurchased self.userStatus = userStatus + self.refundPreference = refundPreference } - public init(customerConsented: Bool? = nil, rawConsumptionStatus: Int32? = nil, rawPlatform: Int32? = nil, sampleContentProvided: Bool? = nil, rawDeliveryStatus: Int32? = nil, appAccountToken: UUID? = nil, rawAccountTenure: Int32? = nil, rawPlayTime: Int32? = nil, rawLifetimeDollarsRefunded: Int32? = nil, rawLifetimeDollarsPurchased: Int32? = nil, rawUserStatus: Int32? = nil) { + public init(customerConsented: Bool? = nil, rawConsumptionStatus: Int32? = nil, rawPlatform: Int32? = nil, sampleContentProvided: Bool? = nil, rawDeliveryStatus: Int32? = nil, appAccountToken: UUID? = nil, rawAccountTenure: Int32? = nil, rawPlayTime: Int32? = nil, rawLifetimeDollarsRefunded: Int32? = nil, rawLifetimeDollarsPurchased: Int32? = nil, rawUserStatus: Int32? = nil, rawRefundPreference: Int32? = nil) { self.customerConsented = customerConsented self.rawConsumptionStatus = rawConsumptionStatus self.rawPlatform = rawPlatform @@ -33,6 +34,7 @@ public struct ConsumptionRequest: Decodable, Encodable, Hashable { self.rawLifetimeDollarsRefunded = rawLifetimeDollarsRefunded self.rawLifetimeDollarsPurchased = rawLifetimeDollarsPurchased self.rawUserStatus = rawUserStatus + self.rawRefundPreference = rawRefundPreference } ///A Boolean value that indicates whether the customer consented to provide consumption data to the App Store. @@ -169,5 +171,20 @@ public struct ConsumptionRequest: Decodable, Encodable, Hashable { ///See ``userStatus`` public var rawUserStatus: Int32? + + ///A value that indicates your preference, based on your operational logic, as to whether Apple should grant the refund. + /// + ///[refundPreference](https://developer.apple.com/documentation/appstoreserverapi/refundpreference) + public var refundPreference: RefundPreference? { + get { + return rawRefundPreference.flatMap { RefundPreference(rawValue: $0) } + } + set { + self.rawRefundPreference = newValue.map { $0.rawValue } + } + } + + ///See ``refundPreference`` + public var rawRefundPreference: Int32? } diff --git a/Sources/AppStoreServerLibrary/Models/ConsumptionRequestReason.swift b/Sources/AppStoreServerLibrary/Models/ConsumptionRequestReason.swift new file mode 100644 index 00000000..a360a684 --- /dev/null +++ b/Sources/AppStoreServerLibrary/Models/ConsumptionRequestReason.swift @@ -0,0 +1,12 @@ +// Copyright (c) 2024 Apple Inc. Licensed under MIT License. + +///The customer-provided reason for a refund request. +/// +///[consumptionRequestReason](https://developer.apple.com/documentation/appstoreservernotifications/consumptionrequestreason) +public enum ConsumptionRequestReason: String, Decodable, Encodable, Hashable { + case unintendedPurchase = "UNINTENDED_PURCHASE" + case fulfillmentIssue = "FULFILLMENT_ISSUE" + case unsatisfiedWithPurchase = "UNSATISFIED_WITH_PURCHASE" + case legal = "LEGAL" + case other = "OTHER" +} diff --git a/Sources/AppStoreServerLibrary/Models/Data.swift b/Sources/AppStoreServerLibrary/Models/Data.swift index bc1b9598..a5cba392 100644 --- a/Sources/AppStoreServerLibrary/Models/Data.swift +++ b/Sources/AppStoreServerLibrary/Models/Data.swift @@ -5,7 +5,7 @@ ///[data](https://developer.apple.com/documentation/appstoreservernotifications/data) public struct Data: Decodable, Encodable, Hashable { - public init(environment: Environment? = nil, appAppleId: Int64? = nil, bundleId: String? = nil, bundleVersion: String? = nil, signedTransactionInfo: String? = nil, signedRenewalInfo: String? = nil, status: Status? = nil) { + public init(environment: Environment? = nil, appAppleId: Int64? = nil, bundleId: String? = nil, bundleVersion: String? = nil, signedTransactionInfo: String? = nil, signedRenewalInfo: String? = nil, status: Status? = nil, consumptionRequestReason: ConsumptionRequestReason? = nil) { self.environment = environment self.appAppleId = appAppleId self.bundleId = bundleId @@ -13,9 +13,10 @@ public struct Data: Decodable, Encodable, Hashable { self.signedTransactionInfo = signedTransactionInfo self.signedRenewalInfo = signedRenewalInfo self.status = status + self.consumptionRequestReason = consumptionRequestReason } - public init(rawEnvironment: String? = nil, appAppleId: Int64? = nil, bundleId: String? = nil, bundleVersion: String? = nil, signedTransactionInfo: String? = nil, signedRenewalInfo: String? = nil, rawStatus: Int32? = nil) { + public init(rawEnvironment: String? = nil, appAppleId: Int64? = nil, bundleId: String? = nil, bundleVersion: String? = nil, signedTransactionInfo: String? = nil, signedRenewalInfo: String? = nil, rawStatus: Int32? = nil, rawConsumptionRequestReason: String? = nil) { self.rawEnvironment = rawEnvironment self.appAppleId = appAppleId self.bundleId = bundleId @@ -23,6 +24,7 @@ public struct Data: Decodable, Encodable, Hashable { self.signedTransactionInfo = signedTransactionInfo self.signedRenewalInfo = signedRenewalInfo self.rawStatus = rawStatus + self.rawConsumptionRequestReason = rawConsumptionRequestReason } ///The server environment that the notification applies to, either sandbox or production. @@ -79,4 +81,19 @@ public struct Data: Decodable, Encodable, Hashable { ///See ``status`` public var rawStatus: Int32? + + ///The reason the customer requested the refund. + /// + ///[consumptionRequestReason](https://developer.apple.com/documentation/appstoreservernotifications/consumptionrequestreason) + public var consumptionRequestReason: ConsumptionRequestReason? { + get { + return rawConsumptionRequestReason.flatMap { ConsumptionRequestReason(rawValue: $0) } + } + set { + self.rawConsumptionRequestReason = newValue.map { $0.rawValue } + } + } + + ///See ``consumptionRequestReason`` + public var rawConsumptionRequestReason: String? } diff --git a/Sources/AppStoreServerLibrary/Models/RefundPreference.swift b/Sources/AppStoreServerLibrary/Models/RefundPreference.swift new file mode 100644 index 00000000..52d39d32 --- /dev/null +++ b/Sources/AppStoreServerLibrary/Models/RefundPreference.swift @@ -0,0 +1,11 @@ +// Copyright (c) 2024 Apple Inc. Licensed under MIT License. + +///A value that indicates your preferred outcome for the refund request. +/// +///[refundPreference](https://developer.apple.com/documentation/appstoreserverapi/refundpreference) +public enum RefundPreference: Int32, Decodable, Encodable, Hashable { + case undeclared = 0 + case preferGrant = 1 + case preferDecline = 2 + case noPreference = 3 +} diff --git a/Sources/AppStoreServerLibrary/Utility.swift b/Sources/AppStoreServerLibrary/Utility.swift index 5dbb736f..d410320f 100644 --- a/Sources/AppStoreServerLibrary/Utility.swift +++ b/Sources/AppStoreServerLibrary/Utility.swift @@ -32,8 +32,8 @@ internal func getJsonEncoder() -> JSONEncoder { private struct RawValueCodingKey: CodingKey { - private static let keysToRawKeys = ["environment": "rawEnvironment", "receiptType": "rawReceiptType", "consumptionStatus": "rawConsumptionStatus", "platform": "rawPlatform", "deliveryStatus": "rawDeliveryStatus", "accountTenure": "rawAccountTenure", "playTime": "rawPlayTime", "lifetimeDollarsRefunded": "rawLifetimeDollarsRefunded", "lifetimeDollarsPurchased": "rawLifetimeDollarsPurchased", "userStatus": "rawUserStatus", "status": "rawStatus", "expirationIntent": "rawExpirationIntent", "priceIncreaseStatus": "rawPriceIncreaseStatus", "offerType": "rawOfferType", "type": "rawType", "inAppOwnershipType": "rawInAppOwnershipType", "revocationReason": "rawRevocationReason", "transactionReason": "rawTransactionReason", "offerDiscountType": "rawOfferDiscountType", "notificationType": "rawNotificationType", "subtype": "rawSubtype", "sendAttemptResult": "rawSendAttemptResult", "autoRenewStatus": "rawAutoRenewStatus"] - private static let rawKeysToKeys = ["rawEnvironment": "environment", "rawReceiptType": "receiptType", "rawConsumptionStatus": "consumptionStatus", "rawPlatform": "platform", "rawDeliveryStatus": "deliveryStatus", "rawAccountTenure": "accountTenure", "rawPlayTime": "playTime", "rawLifetimeDollarsRefunded": "lifetimeDollarsRefunded", "rawLifetimeDollarsPurchased": "lifetimeDollarsPurchased", "rawUserStatus": "userStatus", "rawStatus": "status", "rawExpirationIntent": "expirationIntent", "rawPriceIncreaseStatus": "priceIncreaseStatus", "rawOfferType": "offerType", "rawType": "type", "rawInAppOwnershipType": "inAppOwnershipType", "rawRevocationReason": "revocationReason", "rawTransactionReason": "transactionReason", "rawOfferDiscountType": "offerDiscountType", "rawNotificationType": "notificationType", "rawSubtype": "subtype", "rawSendAttemptResult": "sendAttemptResult", "rawAutoRenewStatus": "autoRenewStatus"] + private static let keysToRawKeys = ["environment": "rawEnvironment", "receiptType": "rawReceiptType", "consumptionStatus": "rawConsumptionStatus", "platform": "rawPlatform", "deliveryStatus": "rawDeliveryStatus", "accountTenure": "rawAccountTenure", "playTime": "rawPlayTime", "lifetimeDollarsRefunded": "rawLifetimeDollarsRefunded", "lifetimeDollarsPurchased": "rawLifetimeDollarsPurchased", "userStatus": "rawUserStatus", "status": "rawStatus", "expirationIntent": "rawExpirationIntent", "priceIncreaseStatus": "rawPriceIncreaseStatus", "offerType": "rawOfferType", "type": "rawType", "inAppOwnershipType": "rawInAppOwnershipType", "revocationReason": "rawRevocationReason", "transactionReason": "rawTransactionReason", "offerDiscountType": "rawOfferDiscountType", "notificationType": "rawNotificationType", "subtype": "rawSubtype", "sendAttemptResult": "rawSendAttemptResult", "autoRenewStatus": "rawAutoRenewStatus", "refundPreference": "rawRefundPeference", "consumptionRequestReason": "rawConsumptionRequestReason"] + private static let rawKeysToKeys = ["rawEnvironment": "environment", "rawReceiptType": "receiptType", "rawConsumptionStatus": "consumptionStatus", "rawPlatform": "platform", "rawDeliveryStatus": "deliveryStatus", "rawAccountTenure": "accountTenure", "rawPlayTime": "playTime", "rawLifetimeDollarsRefunded": "lifetimeDollarsRefunded", "rawLifetimeDollarsPurchased": "lifetimeDollarsPurchased", "rawUserStatus": "userStatus", "rawStatus": "status", "rawExpirationIntent": "expirationIntent", "rawPriceIncreaseStatus": "priceIncreaseStatus", "rawOfferType": "offerType", "rawType": "type", "rawInAppOwnershipType": "inAppOwnershipType", "rawRevocationReason": "revocationReason", "rawTransactionReason": "transactionReason", "rawOfferDiscountType": "offerDiscountType", "rawNotificationType": "notificationType", "rawSubtype": "subtype", "rawSendAttemptResult": "sendAttemptResult", "rawAutoRenewStatus": "autoRenewStatus", "rawRefundPreference": "refundPreference", "rawConsumptionRequestReason": "consumptionRequestReason"] var stringValue: String var intValue: Int? diff --git a/Tests/AppStoreServerLibraryTests/AppStoreServerAPIClientTests.swift b/Tests/AppStoreServerLibraryTests/AppStoreServerAPIClientTests.swift index 8f210e9b..4e642916 100644 --- a/Tests/AppStoreServerLibraryTests/AppStoreServerAPIClientTests.swift +++ b/Tests/AppStoreServerLibraryTests/AppStoreServerAPIClientTests.swift @@ -347,6 +347,7 @@ final class AppStoreServerAPIClientTests: XCTestCase { XCTAssertEqual(6, decodedJson["lifetimeDollarsRefunded"] as! Int) XCTAssertEqual(7, decodedJson["lifetimeDollarsPurchased"] as! Int) XCTAssertEqual(4, decodedJson["userStatus"] as! Int) + XCTAssertEqual(3, decodedJson["refundPreference"] as! Int) } let consumptionRequest = ConsumptionRequest( @@ -360,7 +361,8 @@ final class AppStoreServerAPIClientTests: XCTestCase { playTime: PlayTime.oneDayToFourDays, lifetimeDollarsRefunded: LifetimeDollarsRefunded.oneThousandDollarsToOneThousandNineHundredNinetyNineDollarsAndNinetyNineCents, lifetimeDollarsPurchased: LifetimeDollarsPurchased.twoThousandDollarsOrGreater, - userStatus: UserStatus.limitedAccess + userStatus: UserStatus.limitedAccess, + refundPreference: RefundPreference.noPreference ) let response = await client.sendConsumptionData(transactionId: "49571273", consumptionRequest: consumptionRequest) diff --git a/Tests/AppStoreServerLibraryTests/SignedModelTests.swift b/Tests/AppStoreServerLibraryTests/SignedModelTests.swift index e1b529d7..a067761c 100644 --- a/Tests/AppStoreServerLibraryTests/SignedModelTests.swift +++ b/Tests/AppStoreServerLibraryTests/SignedModelTests.swift @@ -34,6 +34,41 @@ final class SignedModelTests: XCTestCase { XCTAssertEqual("signed_renewal_info_value", notification.data!.signedRenewalInfo) XCTAssertEqual(Status.active, notification.data!.status) XCTAssertEqual(1, notification.data!.rawStatus) + XCTAssertNil(notification.data!.consumptionRequestReason) + XCTAssertNil(notification.data!.rawConsumptionRequestReason) + } + + public func testConsumptionRequestNotificationDecoding() async throws { + let signedNotification = TestingUtility.createSignedDataFromJson("resources/models/signedConsumptionRequestNotification.json") + + let verifiedNotification = await TestingUtility.getSignedDataVerifier().verifyAndDecodeNotification(signedPayload: signedNotification) + + guard case .valid(let notification) = verifiedNotification else { + XCTAssertTrue(false) + return + } + + XCTAssertEqual(NotificationTypeV2.consumptionRequest, notification.notificationType) + XCTAssertEqual("CONSUMPTION_REQUEST", notification.rawNotificationType) + XCTAssertNil(notification.subtype) + XCTAssertNil(notification.rawSubtype) + XCTAssertEqual("002e14d5-51f5-4503-b5a8-c3a1af68eb20", notification.notificationUUID) + XCTAssertEqual("2.0", notification.version) + XCTAssertEqual(Date(timeIntervalSince1970: 1698148900), notification.signedDate) + XCTAssertNotNil(notification.data) + XCTAssertNil(notification.summary) + XCTAssertNil(notification.externalPurchaseToken) + XCTAssertEqual(Environment.localTesting, notification.data!.environment) + XCTAssertEqual("LocalTesting", notification.data!.rawEnvironment) + XCTAssertEqual(41234, notification.data!.appAppleId) + XCTAssertEqual("com.example", notification.data!.bundleId) + XCTAssertEqual("1.2.3", notification.data!.bundleVersion) + XCTAssertEqual("signed_transaction_info_value", notification.data!.signedTransactionInfo) + XCTAssertEqual("signed_renewal_info_value", notification.data!.signedRenewalInfo) + XCTAssertEqual(Status.active, notification.data!.status) + XCTAssertEqual(1, notification.data!.rawStatus) + XCTAssertEqual(ConsumptionRequestReason.unintendedPurchase, notification.data!.consumptionRequestReason) + XCTAssertEqual("UNINTENDED_PURCHASE", notification.data!.rawConsumptionRequestReason) } public func testSummaryNotificationDecoding() async throws { diff --git a/Tests/AppStoreServerLibraryTests/resources/models/signedConsumptionRequestNotification.json b/Tests/AppStoreServerLibraryTests/resources/models/signedConsumptionRequestNotification.json new file mode 100644 index 00000000..56c27c75 --- /dev/null +++ b/Tests/AppStoreServerLibraryTests/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