From b59bf91497225de66fe595584af81f6479566dd6 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Mon, 8 May 2017 21:23:18 +0100 Subject: [PATCH 1/3] Add ReceiptItem to VerifyPurchaseResult, VerifySubscriptionResult --- SwiftyStoreKit/InAppReceipt.swift | 118 +++++++++--- SwiftyStoreKit/SwiftyStoreKit+Types.swift | 29 ++- SwiftyStoreKitTests/InAppReceiptTests.swift | 193 ++++++++------------ 3 files changed, 195 insertions(+), 145 deletions(-) diff --git a/SwiftyStoreKit/InAppReceipt.swift b/SwiftyStoreKit/InAppReceipt.swift index a9986bd2..1f45e3b7 100644 --- a/SwiftyStoreKit/InAppReceipt.swift +++ b/SwiftyStoreKit/InAppReceipt.swift @@ -25,6 +25,59 @@ import Foundation +extension Date { + + init?(millisecondsSince1970: String) { + guard let millisecondsNumber = Double(millisecondsSince1970) else { + return nil + } + self = Date(timeIntervalSince1970: millisecondsNumber / 1000) + } +} + +extension ReceiptItem { + + public init?(receiptInfo: ReceiptInfo) { + guard + let productId = receiptInfo["product_id"] as? String, + let quantityString = receiptInfo["quantity"] as? String, + let quantity = Int(quantityString), + let transactionId = receiptInfo["transaction_id"] as? String, + let originalTransactionId = receiptInfo["original_transaction_id"] as? String, + let purchaseDate = ReceiptItem.parseDate(from: receiptInfo, key: "purchase_date_ms"), + let originalPurchaseDate = ReceiptItem.parseDate(from: receiptInfo, key: "original_purchase_date_ms"), + let webOrderLineItemId = receiptInfo["web_order_line_item_id"] as? String + else { + print("could not parse receipt item: \(receiptInfo). Skipping...") + return nil + } + self.productId = productId + self.quantity = quantity + self.transactionId = transactionId + self.originalTransactionId = originalTransactionId + self.purchaseDate = purchaseDate + self.originalPurchaseDate = originalPurchaseDate + self.webOrderLineItemId = webOrderLineItemId + self.subscriptionExpirationDate = ReceiptItem.parseDate(from: receiptInfo, key: "expires_date_ms") + self.cancellationDate = ReceiptItem.parseDate(from: receiptInfo, key: "cancellation_date_ms") + if let isTrialPeriod = receiptInfo["is_trial_period"] as? String { + self.isTrialPeriod = Bool(isTrialPeriod) ?? false + } else { + self.isTrialPeriod = false + } + } + + private static func parseDate(from receiptInfo: ReceiptInfo, key: String) -> Date? { + + guard + let requestDateString = receiptInfo[key] as? String, + let requestDateMs = Double(requestDateString) else { + return nil + } + return Date(timeIntervalSince1970: requestDateMs / 1000) + } +} + // MARK - receipt mangement internal class InAppReceipt { @@ -81,8 +134,12 @@ internal class InAppReceipt { let receiptsInfo = filterReceiptsInfo(receipts: receipts, withProductId: productId) let nonCancelledReceiptsInfo = receiptsInfo.filter { receipt in receipt["cancellation_date"] == nil } + let receiptItems = nonCancelledReceiptsInfo.flatMap { ReceiptItem(receiptInfo: $0) } // Verify that at least one receipt has the right product id - return nonCancelledReceiptsInfo.count >= 1 ? .purchased : .notPurchased + if let firstItem = receiptItems.first { + return .purchased(item: firstItem) + } + return .notPurchased } /** @@ -101,8 +158,6 @@ internal class InAppReceipt { validUntil date: Date = Date() ) -> VerifySubscriptionResult { - // Verify that at least one receipt has the right product id - // The values of the latest_receipt and latest_receipt_info keys are useful when checking whether an auto-renewable subscription is currently active. By providing any transaction receipt for the subscription and checking these values, you can get information about the currently-active subscription period. If the receipt being validated is for the latest renewal, the value for latest_receipt is the same as receipt-data (in the request) and the value for latest_receipt_info is the same as receipt. let (receipts, duration) = getReceiptsAndDuration(for: type, inReceipt: receipt) let receiptsInfo = filterReceiptsInfo(receipts: receipts, withProductId: productId) @@ -113,37 +168,41 @@ internal class InAppReceipt { let receiptDate = getReceiptRequestDate(inReceipt: receipt) ?? date - // Return the expires dates sorted desc - let expiryDateValues = nonCancelledReceiptsInfo - .flatMap { (receipt) -> String? in + let receiptItems = nonCancelledReceiptsInfo.flatMap { ReceiptItem(receiptInfo: $0) } - let key: String = duration != nil ? "original_purchase_date_ms" : "expires_date_ms" - return receipt[key] as? String - } - .flatMap { (dateString) -> Date? in - guard let doubleValue = Double(dateString) else { return nil } - // If duration is set, create an "expires date" value calculated from the original purchase date - let addedDuration = duration ?? 0 - let expiryDateDouble = (doubleValue / 1000 + addedDuration) - return Date(timeIntervalSince1970: expiryDateDouble) - } - .sorted { (a, b) -> Bool in - // Sort by descending date order - return a.compare(b) == .orderedDescending - } + if nonCancelledReceiptsInfo.count > receiptItems.count { + print("receipt has \(nonCancelledReceiptsInfo.count) items, but only \(receiptItems.count) were parsed") + } + + let sortedExpiryDatesAndItems = expiryDatesAndItems(receiptItems: receiptItems, duration: duration).sorted { a, b in + return a.0 > b.0 + } - guard let firstExpiryDate = expiryDateValues.first else { + guard let firstExpiryDateItemPair = sortedExpiryDatesAndItems.first else { return .notPurchased } - // Check if at least 1 receipt is valid - if firstExpiryDate.compare(receiptDate) == .orderedDescending { + if firstExpiryDateItemPair.0 > receiptDate { + return .purchased(expiryDate: firstExpiryDateItemPair.0, item: firstExpiryDateItemPair.1) + } else { + return .expired(expiryDate: firstExpiryDateItemPair.0, item: firstExpiryDateItemPair.1) + } + } + + private class func expiryDatesAndItems(receiptItems: [ReceiptItem], duration: TimeInterval?) -> [(Date, ReceiptItem)] { - // The subscription is valid - return .purchased(expiryDate: firstExpiryDate) + if let duration = duration { + return receiptItems.map { + let expirationDate = Date(timeIntervalSince1970: $0.originalPurchaseDate.timeIntervalSince1970 + duration) + return (expirationDate, $0) + } } else { - // The subscription is expired - return .expired(expiryDate: firstExpiryDate) + return receiptItems.flatMap { + if let expirationDate = $0.subscriptionExpirationDate { + return (expirationDate, $0) + } + return nil + } } } @@ -159,11 +218,10 @@ internal class InAppReceipt { private class func getReceiptRequestDate(inReceipt receipt: ReceiptInfo) -> Date? { guard let receiptInfo = receipt["receipt"] as? ReceiptInfo, - let requestDateString = receiptInfo["request_date_ms"] as? String, - let requestDateMs = Double(requestDateString) else { + let requestDateString = receiptInfo["request_date_ms"] as? String else { return nil } - return Date(timeIntervalSince1970: requestDateMs / 1000) + return Date(millisecondsSince1970: requestDateString) } /** diff --git a/SwiftyStoreKit/SwiftyStoreKit+Types.swift b/SwiftyStoreKit/SwiftyStoreKit+Types.swift index f0182a84..8f2966fe 100644 --- a/SwiftyStoreKit/SwiftyStoreKit+Types.swift +++ b/SwiftyStoreKit/SwiftyStoreKit+Types.swift @@ -85,14 +85,14 @@ public enum VerifyReceiptResult { // Result for Consumable and NonConsumable public enum VerifyPurchaseResult { - case purchased + case purchased(item: ReceiptItem) case notPurchased } // Verify subscription result public enum VerifySubscriptionResult { - case purchased(expiryDate: Date) - case expired(expiryDate: Date) + case purchased(expiryDate: Date, item: ReceiptItem) + case expired(expiryDate: Date, item: ReceiptItem) case notPurchased } @@ -101,6 +101,29 @@ public enum SubscriptionType { case nonRenewing(validDuration: TimeInterval) } +public struct ReceiptItem { + // The product identifier of the item that was purchased. This value corresponds to the productIdentifier property of the SKPayment object stored in the transaction’s payment property. + public let productId: String + // The number of items purchased. This value corresponds to the quantity property of the SKPayment object stored in the transaction’s payment property. + public let quantity: Int + // The transaction identifier of the item that was purchased. This value corresponds to the transaction’s transactionIdentifier property. + public let transactionId: String + // For a transaction that restores a previous transaction, the transaction identifier of the original transaction. Otherwise, identical to the transaction identifier. This value corresponds to the original transaction’s transactionIdentifier property. All receipts in a chain of renewals for an auto-renewable subscription have the same value for this field. + public let originalTransactionId: String + // The date and time that the item was purchased. This value corresponds to the transaction’s transactionDate property. + public let purchaseDate: Date + // For a transaction that restores a previous transaction, the date of the original transaction. This value corresponds to the original transaction’s transactionDate property. In an auto-renewable subscription receipt, this indicates the beginning of the subscription period, even if the subscription has been renewed. + public let originalPurchaseDate: Date + // The primary key for identifying subscription purchases. + public let webOrderLineItemId: String + // The expiration date for the subscription, expressed as the number of milliseconds since January 1, 1970, 00:00:00 GMT. This key is only present for auto-renewable subscription receipts. + public let subscriptionExpirationDate: Date? + // For a transaction that was canceled by Apple customer support, the time and date of the cancellation. Treat a canceled receipt the same as if no purchase had ever been made. + public let cancellationDate: Date? + + public let isTrialPeriod: Bool +} + // Error when managing receipt public enum ReceiptError: Swift.Error { // No receipt data diff --git a/SwiftyStoreKitTests/InAppReceiptTests.swift b/SwiftyStoreKitTests/InAppReceiptTests.swift index 0ad28ebe..bdf1ffd6 100644 --- a/SwiftyStoreKitTests/InAppReceiptTests.swift +++ b/SwiftyStoreKitTests/InAppReceiptTests.swift @@ -32,66 +32,9 @@ private extension TimeInterval { } } -public struct ReceiptItem { - // The product identifier of the item that was purchased. This value corresponds to the productIdentifier property of the SKPayment object stored in the transaction’s payment property. - public let productId: String - // The number of items purchased. This value corresponds to the quantity property of the SKPayment object stored in the transaction’s payment property. - public let quantity: Int - // The transaction identifier of the item that was purchased. This value corresponds to the transaction’s transactionIdentifier property. - public let transactionId: String - // For a transaction that restores a previous transaction, the transaction identifier of the original transaction. Otherwise, identical to the transaction identifier. This value corresponds to the original transaction’s transactionIdentifier property. All receipts in a chain of renewals for an auto-renewable subscription have the same value for this field. - public let originalTransactionId: String - // The date and time that the item was purchased. This value corresponds to the transaction’s transactionDate property. - public let purchaseDate: Date - // For a transaction that restores a previous transaction, the date of the original transaction. This value corresponds to the original transaction’s transactionDate property. In an auto-renewable subscription receipt, this indicates the beginning of the subscription period, even if the subscription has been renewed. - public let originalPurchaseDate: Date - // The primary key for identifying subscription purchases. - public let webOrderLineItemId: String - // The expiration date for the subscription, expressed as the number of milliseconds since January 1, 1970, 00:00:00 GMT. This key is only present for auto-renewable subscription receipts. - public let subscriptionExpirationDate: Date? - // For a transaction that was canceled by Apple customer support, the time and date of the cancellation. Treat a canceled receipt the same as if no purchase had ever been made. - public let cancellationDate: Date? - - public let isTrialPeriod: Bool? - - public init?(receiptInfo: ReceiptInfo) { - guard - let productId = receiptInfo["product_id"] as? String, - let quantity = receiptInfo["quantity"] as? Int, - let transactionId = receiptInfo["transaction_id"] as? String, - let originalTransactionId = receiptInfo["original_transaction_id"] as? String, - let purchaseDate = ReceiptItem.parseDate(from: receiptInfo, key: "purchase_date_ms"), - let originalPurchaseDate = ReceiptItem.parseDate(from: receiptInfo, key: "original_purchase_date_ms"), - let webOrderLineItemId = receiptInfo["web_order_line_item_id"] as? String - else { - return nil - } - self.productId = productId - self.quantity = quantity - self.transactionId = transactionId - self.originalTransactionId = originalTransactionId - self.purchaseDate = purchaseDate - self.originalPurchaseDate = originalPurchaseDate - self.webOrderLineItemId = webOrderLineItemId - self.subscriptionExpirationDate = ReceiptItem.parseDate(from: receiptInfo, key: "expires_date_ms") - self.cancellationDate = ReceiptItem.parseDate(from: receiptInfo, key: "cancellation_date_ms") - self.isTrialPeriod = receiptInfo["is_trial_period"] as? Bool - } - - private static func parseDate(from receiptInfo: ReceiptInfo, key: String) -> Date? { - - guard - let requestDateString = receiptInfo[key] as? String, - let requestDateMs = Double(requestDateString) else { - return nil - } - return Date(timeIntervalSince1970: requestDateMs / 1000) - } -} - -extension ReceiptItem { +extension ReceiptItem: Equatable { - init(productId: String, purchaseDate: Date, subscriptionExpirationDate: Date? = nil, cancellationDate: Date? = nil, isTrialPeriod: Bool? = nil) { + init(productId: String, purchaseDate: Date, subscriptionExpirationDate: Date? = nil, cancellationDate: Date? = nil, isTrialPeriod: Bool = false) { self.productId = productId self.quantity = 1 self.purchaseDate = purchaseDate @@ -109,7 +52,11 @@ extension ReceiptItem { "product_id": productId as NSString, "quantity": String(quantity) as NSString, "purchase_date_ms": purchaseDate.timeIntervalSince1970.millisecondsNSString, - "original_purchase_date_ms": originalPurchaseDate.timeIntervalSince1970.millisecondsNSString + "original_purchase_date_ms": originalPurchaseDate.timeIntervalSince1970.millisecondsNSString, + "is_trial_period": (isTrialPeriod ? "1" : "0") as NSString, + "transaction_id": transactionId as NSString, + "original_transaction_id": originalTransactionId as NSString, + "web_order_line_item_id": webOrderLineItemId as NSString ] if let subscriptionExpirationDate = subscriptionExpirationDate { result["expires_date_ms"] = subscriptionExpirationDate.timeIntervalSince1970.millisecondsNSString @@ -118,11 +65,19 @@ extension ReceiptItem { result["cancellation_date_ms"] = cancellationDate.timeIntervalSince1970.millisecondsNSString result["cancellation_date"] = cancellationDate as NSDate } - if let isTrialPeriod = isTrialPeriod { - result["is_trial_period"] = NSNumber(value: isTrialPeriod) - } return NSDictionary(dictionary: result) } + + public static func == (lhs: ReceiptItem, rhs: ReceiptItem) -> Bool { + return + lhs.productId == rhs.productId && + lhs.quantity == rhs.quantity && + lhs.purchaseDate == rhs.purchaseDate && + lhs.originalPurchaseDate == rhs.originalPurchaseDate && + lhs.subscriptionExpirationDate == rhs.subscriptionExpirationDate && + lhs.cancellationDate == rhs.cancellationDate && + lhs.isTrialPeriod == rhs.isTrialPeriod + } } extension VerifySubscriptionResult: Equatable { @@ -130,8 +85,22 @@ extension VerifySubscriptionResult: Equatable { public static func == (lhs: VerifySubscriptionResult, rhs: VerifySubscriptionResult) -> Bool { switch (lhs, rhs) { case (.notPurchased, .notPurchased): return true - case (.purchased(let lhsExpiryDate), .purchased(let rhsExpiryDate)): return lhsExpiryDate == rhsExpiryDate - case (.expired(let lhsExpiryDate), .expired(let rhsExpiryDate)): return lhsExpiryDate == rhsExpiryDate + case (.purchased(let lhsExpiryDate, let lhsReceiptItem), .purchased(let rhsExpiryDate, let rhsReceiptItem)): + return lhsExpiryDate == rhsExpiryDate && lhsReceiptItem == rhsReceiptItem + case (.expired(let lhsExpiryDate, let lhsReceiptItem), .expired(let rhsExpiryDate, let rhsReceiptItem)): + return lhsExpiryDate == rhsExpiryDate && lhsReceiptItem == rhsReceiptItem + default: return false + } + } +} + +extension VerifyPurchaseResult: Equatable { + + public static func == (lhs: VerifyPurchaseResult, rhs: VerifyPurchaseResult) -> Bool { + switch (lhs, rhs) { + case (.notPurchased, .notPurchased): return true + case (.purchased(let lhsReceiptItem), .purchased(let rhsReceiptItem)): + return lhsReceiptItem == rhsReceiptItem default: return false } } @@ -141,55 +110,55 @@ class InAppReceiptTests: XCTestCase { // MARK: Verify Purchase func testVerifyPurchase_when_noPurchases_then_resultIsNotPurchased() { - + let receiptRequestDate = makeDateAtMidnight(year: 2017, month: 5, day: 14) let productId = "product1" let receipt = makeReceipt(items: [], requestDate: receiptRequestDate) - + let verifyPurchaseResult = SwiftyStoreKit.verifyPurchase(productId: productId, inReceipt: receipt) - + XCTAssertEqual(verifyPurchaseResult, .notPurchased) } func testVerifyPurchase_when_onePurchase_then_resultIsPurchased() { - + let receiptRequestDate = makeDateAtMidnight(year: 2017, month: 5, day: 14) let productId = "product1" let item = ReceiptItem(productId: productId, purchaseDate: receiptRequestDate, subscriptionExpirationDate: nil, cancellationDate: nil, isTrialPeriod: false) let receipt = makeReceipt(items: [item], requestDate: receiptRequestDate) - + let verifyPurchaseResult = SwiftyStoreKit.verifyPurchase(productId: productId, inReceipt: receipt) - - XCTAssertEqual(verifyPurchaseResult, .purchased) + + XCTAssertEqual(verifyPurchaseResult, .purchased(item: item)) } func testVerifyPurchase_when_oneCancelledPurchase_then_resultIsNotPurchased() { - + let receiptRequestDate = makeDateAtMidnight(year: 2017, month: 5, day: 14) let productId = "product1" let item = ReceiptItem(productId: productId, purchaseDate: receiptRequestDate, subscriptionExpirationDate: nil, cancellationDate: receiptRequestDate, isTrialPeriod: false) let receipt = makeReceipt(items: [item], requestDate: receiptRequestDate) - + let verifyPurchaseResult = SwiftyStoreKit.verifyPurchase(productId: productId, inReceipt: receipt) - + XCTAssertEqual(verifyPurchaseResult, .notPurchased) } - + // MARK: Verify Subscription, single receipt item tests // auto-renewable, not purchased func testVerifyAutoRenewableSubscription_when_noSubscriptions_then_resultIsNotPurchased() { - + let receiptRequestDate = makeDateAtMidnight(year: 2017, month: 5, day: 14) let productId = "product1" let receipt = makeReceipt(items: [], requestDate: receiptRequestDate) - + let verifySubscriptionResult = SwiftyStoreKit.verifySubscription(type: .autoRenewable, productId: productId, inReceipt: receipt) - + let expectedSubscriptionResult = VerifySubscriptionResult.notPurchased XCTAssertEqual(verifySubscriptionResult, expectedSubscriptionResult) } // auto-renewable, expired func testVerifyAutoRenewableSubscription_when_oneExpiredSubscription_then_resultIsExpired() { - + let receiptRequestDate = makeDateAtMidnight(year: 2017, month: 5, day: 15) let productId = "product1" let isTrialPeriod = false @@ -197,10 +166,10 @@ class InAppReceiptTests: XCTestCase { let expirationDate = purchaseDate.addingTimeInterval(60 * 60) let item = ReceiptItem(productId: productId, purchaseDate: purchaseDate, subscriptionExpirationDate: expirationDate, cancellationDate: nil, isTrialPeriod: isTrialPeriod) let receipt = makeReceipt(items: [item], requestDate: receiptRequestDate) - + let verifySubscriptionResult = SwiftyStoreKit.verifySubscription(type: .autoRenewable, productId: productId, inReceipt: receipt) - - let expectedSubscriptionResult = VerifySubscriptionResult.expired(expiryDate: expirationDate) + + let expectedSubscriptionResult = VerifySubscriptionResult.expired(expiryDate: expirationDate, item: item) XCTAssertEqual(verifySubscriptionResult, expectedSubscriptionResult) } @@ -217,13 +186,13 @@ class InAppReceiptTests: XCTestCase { let verifySubscriptionResult = SwiftyStoreKit.verifySubscription(type: .autoRenewable, productId: productId, inReceipt: receipt) - let expectedSubscriptionResult = VerifySubscriptionResult.purchased(expiryDate: expirationDate) + let expectedSubscriptionResult = VerifySubscriptionResult.purchased(expiryDate: expirationDate, item: item) XCTAssertEqual(verifySubscriptionResult, expectedSubscriptionResult) } // auto-renewable, cancelled func testVerifyAutoRenewableSubscription_when_oneCancelledSubscription_then_resultIsNotPurchased() { - + let receiptRequestDate = makeDateAtMidnight(year: 2017, month: 5, day: 14) let productId = "product1" let isTrialPeriod = false @@ -232,29 +201,29 @@ class InAppReceiptTests: XCTestCase { let cancelledDate = purchaseDate.addingTimeInterval(30 * 60) let item = ReceiptItem(productId: productId, purchaseDate: purchaseDate, subscriptionExpirationDate: expirationDate, cancellationDate: cancelledDate, isTrialPeriod: isTrialPeriod) let receipt = makeReceipt(items: [item], requestDate: receiptRequestDate) - + let verifySubscriptionResult = SwiftyStoreKit.verifySubscription(type: .autoRenewable, productId: productId, inReceipt: receipt) - + let expectedSubscriptionResult = VerifySubscriptionResult.notPurchased XCTAssertEqual(verifySubscriptionResult, expectedSubscriptionResult) } - + // non-renewing, non purchased func testVerifyNonRenewingSubscription_when_noSubscriptions_then_resultIsNotPurchased() { - + let receiptRequestDate = makeDateAtMidnight(year: 2017, month: 5, day: 14) let productId = "product1" let receipt = makeReceipt(items: [], requestDate: receiptRequestDate) - + let verifySubscriptionResult = SwiftyStoreKit.verifySubscription(type: .nonRenewing(validDuration: 60 * 60), productId: productId, inReceipt: receipt) - + let expectedSubscriptionResult = VerifySubscriptionResult.notPurchased XCTAssertEqual(verifySubscriptionResult, expectedSubscriptionResult) } - + // non-renewing, expired func testVerifyNonRenewingSubscription_when_oneExpiredSubscription_then_resultIsExpired() { - + let receiptRequestDate = makeDateAtMidnight(year: 2017, month: 5, day: 15) let productId = "product1" let isTrialPeriod = false @@ -264,16 +233,16 @@ class InAppReceiptTests: XCTestCase { let item = ReceiptItem(productId: productId, purchaseDate: purchaseDate, subscriptionExpirationDate: nil, cancellationDate: nil, isTrialPeriod: isTrialPeriod) let receipt = makeReceipt(items: [item], requestDate: receiptRequestDate) - + let verifySubscriptionResult = SwiftyStoreKit.verifySubscription(type: .nonRenewing(validDuration: duration), productId: productId, inReceipt: receipt) - - let expectedSubscriptionResult = VerifySubscriptionResult.expired(expiryDate: expirationDate) + + let expectedSubscriptionResult = VerifySubscriptionResult.expired(expiryDate: expirationDate, item: item) XCTAssertEqual(verifySubscriptionResult, expectedSubscriptionResult) } // non-renewing, purchased func testVerifyNonRenewingSubscription_when_oneNonExpiredSubscription_then_resultIsPurchased() { - + let receiptRequestDate = makeDateAtMidnight(year: 2017, month: 5, day: 14) let productId = "product1" let isTrialPeriod = false @@ -283,16 +252,16 @@ class InAppReceiptTests: XCTestCase { let item = ReceiptItem(productId: productId, purchaseDate: purchaseDate, subscriptionExpirationDate: nil, cancellationDate: nil, isTrialPeriod: isTrialPeriod) let receipt = makeReceipt(items: [item], requestDate: receiptRequestDate) - + let verifySubscriptionResult = SwiftyStoreKit.verifySubscription(type: .nonRenewing(validDuration: duration), productId: productId, inReceipt: receipt) - - let expectedSubscriptionResult = VerifySubscriptionResult.purchased(expiryDate: expirationDate) + + let expectedSubscriptionResult = VerifySubscriptionResult.purchased(expiryDate: expirationDate, item: item) XCTAssertEqual(verifySubscriptionResult, expectedSubscriptionResult) } // non-renewing, cancelled func testVerifyNonRenewingSubscription_when_oneCancelledSubscription_then_resultIsNotPurchased() { - + let receiptRequestDate = makeDateAtMidnight(year: 2017, month: 5, day: 14) let productId = "product1" let isTrialPeriod = false @@ -301,21 +270,21 @@ class InAppReceiptTests: XCTestCase { let cancelledDate = purchaseDate.addingTimeInterval(30 * 60) let item = ReceiptItem(productId: productId, purchaseDate: purchaseDate, subscriptionExpirationDate: nil, cancellationDate: cancelledDate, isTrialPeriod: isTrialPeriod) let receipt = makeReceipt(items: [item], requestDate: receiptRequestDate) - + let verifySubscriptionResult = SwiftyStoreKit.verifySubscription(type: .nonRenewing(validDuration: duration), productId: productId, inReceipt: receipt) - + let expectedSubscriptionResult = VerifySubscriptionResult.notPurchased XCTAssertEqual(verifySubscriptionResult, expectedSubscriptionResult) } // MARK: Verify Subscription, multiple receipt item tests func verifyAutoRenewableSubscription_when_twoSubscriptions_sameProductId_mostRecentNonExpired_then_resultIsPurchased() { - + let receiptRequestDate = makeDateAtMidnight(year: 2017, month: 5, day: 14) let productId = "product1" let isTrialPeriod = false - + let olderPurchaseDate = makeDateAtMidnight(year: 2017, month: 5, day: 12) let olderExpirationDate = olderPurchaseDate.addingTimeInterval(60 * 60) let olderItem = ReceiptItem(productId: productId, @@ -323,7 +292,7 @@ class InAppReceiptTests: XCTestCase { subscriptionExpirationDate: olderExpirationDate, cancellationDate: nil, isTrialPeriod: isTrialPeriod) - + let newerPurchaseDate = makeDateAtMidnight(year: 2017, month: 5, day: 14) let newerExpirationDate = olderPurchaseDate.addingTimeInterval(60 * 60) let newerItem = ReceiptItem(productId: productId, @@ -331,12 +300,12 @@ class InAppReceiptTests: XCTestCase { subscriptionExpirationDate: newerExpirationDate, cancellationDate: nil, isTrialPeriod: isTrialPeriod) - + let receipt = makeReceipt(items: [olderItem, newerItem], requestDate: receiptRequestDate) - + let verifySubscriptionResult = SwiftyStoreKit.verifySubscription(type: .autoRenewable, productId: productId, inReceipt: receipt) - - let expectedSubscriptionResult = VerifySubscriptionResult.purchased(expiryDate: newerExpirationDate) + + let expectedSubscriptionResult = VerifySubscriptionResult.purchased(expiryDate: newerExpirationDate, item: newerItem) XCTAssertEqual(verifySubscriptionResult, expectedSubscriptionResult) } @@ -344,11 +313,11 @@ class InAppReceiptTests: XCTestCase { func makeReceipt(items: [ReceiptItem], requestDate: Date) -> [String: AnyObject] { let receiptInfos = items.map { $0.receiptInfo } - + // Creating this with NSArray results in __NSSingleObjectArrayI which fails the cast to [String: AnyObject] let array = NSMutableArray() array.addObjects(from: receiptInfos) - + return [ //"latest_receipt": [:], "status": "200" as NSString, From 3041fdb66e68d0407c7ebc030d26e3799071e104 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Mon, 8 May 2017 21:55:58 +0100 Subject: [PATCH 2/3] Modify VerifySubscriptionResult to make all subscriptions accessible when the result is .purchased or .expired. Subscriptions are returned as a ReceiptItem array ordered by expiryDate, with the first one being the newest. --- SwiftyStoreKit/InAppReceipt.swift | 5 ++- SwiftyStoreKit/SwiftyStoreKit+Types.swift | 4 +- SwiftyStoreKitTests/InAppReceiptTests.swift | 43 ++++++++++++++++++--- 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/SwiftyStoreKit/InAppReceipt.swift b/SwiftyStoreKit/InAppReceipt.swift index 1f45e3b7..24f8db08 100644 --- a/SwiftyStoreKit/InAppReceipt.swift +++ b/SwiftyStoreKit/InAppReceipt.swift @@ -182,10 +182,11 @@ internal class InAppReceipt { return .notPurchased } + let sortedReceiptItems = sortedExpiryDatesAndItems.map { $0.1 } if firstExpiryDateItemPair.0 > receiptDate { - return .purchased(expiryDate: firstExpiryDateItemPair.0, item: firstExpiryDateItemPair.1) + return .purchased(expiryDate: firstExpiryDateItemPair.0, items: sortedReceiptItems) } else { - return .expired(expiryDate: firstExpiryDateItemPair.0, item: firstExpiryDateItemPair.1) + return .expired(expiryDate: firstExpiryDateItemPair.0, items: sortedReceiptItems) } } diff --git a/SwiftyStoreKit/SwiftyStoreKit+Types.swift b/SwiftyStoreKit/SwiftyStoreKit+Types.swift index 8f2966fe..fc05c222 100644 --- a/SwiftyStoreKit/SwiftyStoreKit+Types.swift +++ b/SwiftyStoreKit/SwiftyStoreKit+Types.swift @@ -91,8 +91,8 @@ public enum VerifyPurchaseResult { // Verify subscription result public enum VerifySubscriptionResult { - case purchased(expiryDate: Date, item: ReceiptItem) - case expired(expiryDate: Date, item: ReceiptItem) + case purchased(expiryDate: Date, items: [ReceiptItem]) + case expired(expiryDate: Date, items: [ReceiptItem]) case notPurchased } diff --git a/SwiftyStoreKitTests/InAppReceiptTests.swift b/SwiftyStoreKitTests/InAppReceiptTests.swift index bdf1ffd6..581cc867 100644 --- a/SwiftyStoreKitTests/InAppReceiptTests.swift +++ b/SwiftyStoreKitTests/InAppReceiptTests.swift @@ -169,7 +169,7 @@ class InAppReceiptTests: XCTestCase { let verifySubscriptionResult = SwiftyStoreKit.verifySubscription(type: .autoRenewable, productId: productId, inReceipt: receipt) - let expectedSubscriptionResult = VerifySubscriptionResult.expired(expiryDate: expirationDate, item: item) + let expectedSubscriptionResult = VerifySubscriptionResult.expired(expiryDate: expirationDate, items: [item]) XCTAssertEqual(verifySubscriptionResult, expectedSubscriptionResult) } @@ -186,7 +186,7 @@ class InAppReceiptTests: XCTestCase { let verifySubscriptionResult = SwiftyStoreKit.verifySubscription(type: .autoRenewable, productId: productId, inReceipt: receipt) - let expectedSubscriptionResult = VerifySubscriptionResult.purchased(expiryDate: expirationDate, item: item) + let expectedSubscriptionResult = VerifySubscriptionResult.purchased(expiryDate: expirationDate, items: [item]) XCTAssertEqual(verifySubscriptionResult, expectedSubscriptionResult) } @@ -236,7 +236,7 @@ class InAppReceiptTests: XCTestCase { let verifySubscriptionResult = SwiftyStoreKit.verifySubscription(type: .nonRenewing(validDuration: duration), productId: productId, inReceipt: receipt) - let expectedSubscriptionResult = VerifySubscriptionResult.expired(expiryDate: expirationDate, item: item) + let expectedSubscriptionResult = VerifySubscriptionResult.expired(expiryDate: expirationDate, items: [item]) XCTAssertEqual(verifySubscriptionResult, expectedSubscriptionResult) } @@ -255,7 +255,7 @@ class InAppReceiptTests: XCTestCase { let verifySubscriptionResult = SwiftyStoreKit.verifySubscription(type: .nonRenewing(validDuration: duration), productId: productId, inReceipt: receipt) - let expectedSubscriptionResult = VerifySubscriptionResult.purchased(expiryDate: expirationDate, item: item) + let expectedSubscriptionResult = VerifySubscriptionResult.purchased(expiryDate: expirationDate, items: [item]) XCTAssertEqual(verifySubscriptionResult, expectedSubscriptionResult) } @@ -278,7 +278,7 @@ class InAppReceiptTests: XCTestCase { } // MARK: Verify Subscription, multiple receipt item tests - func verifyAutoRenewableSubscription_when_twoSubscriptions_sameProductId_mostRecentNonExpired_then_resultIsPurchased() { + func verifyAutoRenewableSubscription_when_twoSubscriptions_sameProductId_mostRecentNonExpired_then_resultIsPurchased_itemsSorted() { let receiptRequestDate = makeDateAtMidnight(year: 2017, month: 5, day: 14) @@ -305,7 +305,38 @@ class InAppReceiptTests: XCTestCase { let verifySubscriptionResult = SwiftyStoreKit.verifySubscription(type: .autoRenewable, productId: productId, inReceipt: receipt) - let expectedSubscriptionResult = VerifySubscriptionResult.purchased(expiryDate: newerExpirationDate, item: newerItem) + let expectedSubscriptionResult = VerifySubscriptionResult.purchased(expiryDate: newerExpirationDate, items: [newerItem, olderItem]) + XCTAssertEqual(verifySubscriptionResult, expectedSubscriptionResult) + } + + func verifyAutoRenewableSubscription_when_twoSubscriptions_sameProductId_bothExpired_then_resultIsExpired_itemsSorted() { + + let receiptRequestDate = makeDateAtMidnight(year: 2017, month: 5, day: 14) + + let productId = "product1" + let isTrialPeriod = false + + let olderPurchaseDate = makeDateAtMidnight(year: 2017, month: 5, day: 12) + let olderExpirationDate = olderPurchaseDate.addingTimeInterval(60 * 60) + let olderItem = ReceiptItem(productId: productId, + purchaseDate: olderPurchaseDate, + subscriptionExpirationDate: olderExpirationDate, + cancellationDate: nil, + isTrialPeriod: isTrialPeriod) + + let newerPurchaseDate = makeDateAtMidnight(year: 2017, month: 5, day: 13) + let newerExpirationDate = olderPurchaseDate.addingTimeInterval(60 * 60) + let newerItem = ReceiptItem(productId: productId, + purchaseDate: newerPurchaseDate, + subscriptionExpirationDate: newerExpirationDate, + cancellationDate: nil, + isTrialPeriod: isTrialPeriod) + + let receipt = makeReceipt(items: [olderItem, newerItem], requestDate: receiptRequestDate) + + let verifySubscriptionResult = SwiftyStoreKit.verifySubscription(type: .autoRenewable, productId: productId, inReceipt: receipt) + + let expectedSubscriptionResult = VerifySubscriptionResult.expired(expiryDate: newerExpirationDate, items: [newerItem, olderItem]) XCTAssertEqual(verifySubscriptionResult, expectedSubscriptionResult) } From 3a97ba77a6a2e8458d7f66bb9ea329da5e9ec6d3 Mon Sep 17 00:00:00 2001 From: Andrea Bizzotto Date: Mon, 8 May 2017 22:16:24 +0100 Subject: [PATCH 3/3] Update documentation about verify subscriptions. --- README.md | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index a854a01c..5051fdde 100644 --- a/README.md +++ b/README.md @@ -252,8 +252,8 @@ SwiftyStoreKit.verifyReceipt(using: appleValidator, password: "your-shared-secre inReceipt: receipt) switch purchaseResult { - case .purchased(let expiresDate): - print("Product is purchased.") + case .purchased(let receiptItem): + print("Product is purchased: \(receiptItem)") case .notPurchased: print("The user has never purchased this product") } @@ -263,13 +263,17 @@ SwiftyStoreKit.verifyReceipt(using: appleValidator, password: "your-shared-secre } ``` -Note that for consumable products, the receipt will only include the information for a couples of minutes after the purchase. +Note that for consumable products, the receipt will only include the information for a couple of minutes after the purchase. ### Verify Subscription This can be used to check if a subscription was previously purchased, and whether it is still active or if it's expired. -If a subscription has been purchased multiple times, this method will return `.purchased` or `.expired` for the most recent one. +From [Apple - Working with Subscriptions](https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/StoreKitGuide/Chapters/Subscriptions.html#//apple_ref/doc/uid/TP40008267-CH7-SW6): + +> keep a record of the date that each piece of content is published. Read the Original Purchase Date and Subscription Expiration Date field from each receipt entry to determine the start and end dates of the subscription. + +When one or more subscriptions are found for a given product id, they are returned as a `ReceiptItem` array ordered by `expiryDate`, with the first one being the newest. ```swift let appleValidator = AppleReceiptValidator(service: .production) @@ -283,10 +287,10 @@ SwiftyStoreKit.verifyReceipt(using: appleValidator, password: "your-shared-secre inReceipt: receipt) switch purchaseResult { - case .purchased(let expiresDate): - print("Product is valid until \(expiresDate)") - case .expired(let expiresDate): - print("Product is expired since \(expiresDate)") + case .purchased(let expiryDate, let receiptItems): + print("Product is valid until \(expiryDate)") + case .expired(let expiryDate, let receiptItems): + print("Product is expired since \(expiryDate)") case .notPurchased: print("The user has never purchased this product") } @@ -340,9 +344,9 @@ SwiftyStoreKit.purchaseProduct(productId, atomically: true) { result in inReceipt: receipt) switch purchaseResult { - case .purchased(let expiryDate): + case .purchased(let expiryDate, let receiptItems): print("Product is valid until \(expiryDate)") - case .expired(let expiryDate): + case .expired(let expiryDate, let receiptItems): print("Product is expired since \(expiryDate)") case .notPurchased: print("This product has never been purchased")