Ver código fonte

Merge pull request #634 from JustinGanzer/develop

Support for "Billing Grace Period"
Sam Spencer 4 anos atrás
pai
commit
f597336372

+ 100 - 7
Sources/SwiftyStoreKit/InAppReceipt.swift

@@ -164,6 +164,20 @@ internal class InAppReceipt {
         let sortedReceiptItems = sortedExpiryDatesAndItems.map { $0.1 }
         let sortedReceiptItems = sortedExpiryDatesAndItems.map { $0.1 }
         if firstExpiryDateItemPair.0 > receiptDate {
         if firstExpiryDateItemPair.0 > receiptDate {
             return .purchased(expiryDate: firstExpiryDateItemPair.0, items: sortedReceiptItems)
             return .purchased(expiryDate: firstExpiryDateItemPair.0, items: sortedReceiptItems)
+        } else if type == .autoRenewable {
+            
+            //Apple will provide an array by the name "pending_renewal_info" only IF the receipt contains auto-renewable subscriptions. If the application has the grace period feature enabled, entries of the array may have the key "grace_period_expires_date_ms" set in case of billing errors and the likes. If this date is set in the future, the purchase is to be regarded as "in grace period", effectively providing the user with the purchases content up until the point the grace period ends.
+            let matchingRenewals = extractPendingRenewals(inReceipt: receipt, withProductIds: productIds)
+            let filteredExpiryDatesAndRenewals = filterPendingRenewals(renewals: matchingRenewals, toExpireAfter: receiptDate)
+            
+            if filteredExpiryDatesAndRenewals.isEmpty == false {
+                let sortedExpiryDatesAndRenewals = filteredExpiryDatesAndRenewals.sorted(by: {$0.date > $1.date})
+                let sortedPresentRenewals = sortedExpiryDatesAndRenewals.map { $0.info }
+                let originalReceiptItems = getOriginalReceiptItems(fromPendingRenwalInfos: sortedPresentRenewals, foundIn: receipts ?? [])
+                return .inGracePeriod(endDate: sortedExpiryDatesAndRenewals.first!.0, items: originalReceiptItems, pendingRenewals: sortedPresentRenewals)
+            } else {
+                return .expired(expiryDate: firstExpiryDateItemPair.0, items: sortedReceiptItems)
+            }
         } else {
         } else {
             return .expired(expiryDate: firstExpiryDateItemPair.0, items: sortedReceiptItems)
             return .expired(expiryDate: firstExpiryDateItemPair.0, items: sortedReceiptItems)
         }
         }
@@ -261,25 +275,104 @@ internal class InAppReceipt {
     }
     }
 
 
     /**
     /**
-     *  Get all the receipts info for a specific product
+     *  Get all the receipts info for a set of product ids
      *  - Parameter receipts: the receipts array to grab info from
      *  - Parameter receipts: the receipts array to grab info from
-     *  - Parameter productId: the product id
+     *  - Parameter productIds: the set of product ids
      */
      */
     private class func filterReceiptsInfo(receipts: [ReceiptInfo]?, withProductIds productIds: Set<String>) -> [ReceiptInfo] {
     private class func filterReceiptsInfo(receipts: [ReceiptInfo]?, withProductIds productIds: Set<String>) -> [ReceiptInfo] {
+        return filterReceiptsInfo(receipts: receipts, withValues: productIds, forKey: "product_id")
+    }
+    
+    /**
+     *  Get all the receipts info for a set of transaction ids
+     *  - Parameter receipts: the receipts array to grab info from
+     *  - Parameter transactionIds: the set of transaction ids
+     */
+    private class func filterReceiptsInfo(receipts: [ReceiptInfo]?, withTransactionIds transactionIds: Set<String>) -> [ReceiptInfo] {
+        return filterReceiptsInfo(receipts: receipts, withValues: transactionIds, forKey: "transaction_id")
+    }
+    
+    private class func filterReceiptsInfo<T>(receipts: [ReceiptInfo]?, withValues values: Set<T>, forKey key: String) -> [ReceiptInfo] {
 
 
         guard let receipts = receipts else {
         guard let receipts = receipts else {
             return []
             return []
         }
         }
 
 
-        // Filter receipts with matching product ids
-        let receiptsMatchingProductIds = receipts
+        // Filter receipts with matching values
+        let receiptsMatchingConstraint = receipts
             .filter { (receipt) -> Bool in
             .filter { (receipt) -> Bool in
-                if let productId = receipt["product_id"] as? String {
-                    return productIds.contains(productId)
+                if let value = receipt[key] as? T {
+                    return values.contains(value)
                 }
                 }
                 return false
                 return false
             }
             }
 
 
-        return receiptsMatchingProductIds
+        return receiptsMatchingConstraint
+    }
+    
+    /// Retrieves all `PendingRenewalInfo` from the `pending_renewal_info` array which resides in the receipt and belong to a specific set of product IDs.
+    /// - Parameters:
+    ///   - receipt: The receipt in which to seek for the `pending_renewal_info` array
+    ///   - productIds: A set of productIds which the `PendingRenewalInfo` must represent
+    /// - Returns: An array of `PendingRenewalInfo` found to be representing the specified product IDs
+    private class func extractPendingRenewals(inReceipt receipt: ReceiptInfo, withProductIds productIds: Set<String>) -> [PendingRenewalInfo] {
+        let renewals: [PendingRenewalInfo] = {
+            guard let dictionaries = receipt[PendingRenewalInfo.KEY_IN_RESPONSE_BODY] as? [ReceiptInfo] else { return [] }
+            //Avoid exceptions by validifying the json object
+            guard JSONSerialization.isValidJSONObject(dictionaries) else { return [] }
+            guard let data = try? JSONSerialization.data(withJSONObject: dictionaries, options: .prettyPrinted) else { return [] }
+            guard let array = try? JSONDecoder().decode([PendingRenewalInfo].self, from: data) else { return [] }
+            return array
+        }()
+        
+        let matchingRenewals = renewals.filter { (renewal) in
+            let renewalIds = Set([renewal.autoRenewProductId,
+                                  renewal.productId])
+            return renewalIds.intersection(productIds).isEmpty == false
+        }
+        
+        return matchingRenewals
+    }
+    
+    /// Filters `PendingRenewalInfo` by the existance of a `grace_period_expires_date_ms` field and asserting that the date is greater than the provided date.
+    /// - Parameters:
+    ///   - renewals: Array of `PendingRenewalInfo` to be filtered
+    ///   - expiryDate: The date by which to filter
+    /// - Returns: A filtered array of `PendingRenewalInfo` but in the form of tuples, where the first value is the date their grace period will end and the second being the `PendingRenewalInfo`
+    private class func filterPendingRenewals(renewals: [PendingRenewalInfo], toExpireAfter expiryDate: Date) -> [(date: Date, info: PendingRenewalInfo)] {
+        let tuplingFunction: ((PendingRenewalInfo) -> (Date, PendingRenewalInfo)?) = { (renewal) in
+            guard let date = renewal.gracePeriodExpiresDateMSToDate else { return nil }
+            guard date > expiryDate else { return nil }
+            return (date, renewal)
+        }
+        
+        #if swift(>=4.1)
+        let presentRenewals = renewals.compactMap { tuplingFunction($0) }
+        #else
+        let presentRenewals = renewals.flatMap { tuplingFunction($0) }
+        #endif
+        return presentRenewals
+    }
+    
+    /// Finds `ReceiptItem` to which the passed `PendingRenewalInfo` belong.
+    /// - Parameters:
+    ///   - renewals: `PendingRenewalInfo` whose `original_transaction_id` field is inspected
+    ///   - receipts: An array of `ReceiptInfo` representing individual purchases, the likes of which is found when accessing the `latest_receipt_info` field of the Apple Receipt.
+    /// - Returns: An array of `ReceiptItem` where each item corresponding to an input `PendingRenewalInfo`. The array matches the order of the input `PendingRenewalInfo` as closely as possible, unless a corresponding `ReceiptItem` was not found. Which in theory should not happend, but isn't strongly enforced.
+    private class func getOriginalReceiptItems(fromPendingRenwalInfos renewals: [PendingRenewalInfo], foundIn receipts: [ReceiptInfo]) -> [ReceiptItem] {
+        let transactionIds = Set(renewals.map({$0.originalTransactionId}))
+        let receiptsInfo = filterReceiptsInfo(receipts: receipts, withTransactionIds: transactionIds)
+        #if swift(>=4.1)
+            let receiptItems = receiptsInfo.compactMap { ReceiptItem(receiptInfo: $0) }
+        #else
+            let receiptItems = receiptsInfo.flatMap { ReceiptItem(receiptInfo: $0) }
+        #endif
+        
+        var dict = [String: Int]()
+        renewals.enumerated().forEach({dict[$0.element.productId] = $0.offset})
+        
+        return receiptItems.sorted { a, b in
+            dict[a.productId]! < dict[b.productId]!
+        }
     }
     }
 }
 }

+ 123 - 0
Sources/SwiftyStoreKit/SwiftyStoreKit+Types.swift

@@ -172,6 +172,7 @@ public enum VerifyPurchaseResult {
 public enum VerifySubscriptionResult {
 public enum VerifySubscriptionResult {
     case purchased(expiryDate: Date, items: [ReceiptItem])
     case purchased(expiryDate: Date, items: [ReceiptItem])
     case expired(expiryDate: Date, items: [ReceiptItem])
     case expired(expiryDate: Date, items: [ReceiptItem])
+    case inGracePeriod(endDate: Date, items: [ReceiptItem], pendingRenewals: [PendingRenewalInfo])
     case notPurchased
     case notPurchased
 }
 }
 
 
@@ -250,6 +251,128 @@ public enum ReceiptError: Swift.Error {
     case receiptInvalid(receipt: ReceiptInfo, status: ReceiptStatus)
     case receiptInvalid(receipt: ReceiptInfo, status: ReceiptStatus)
 }
 }
 
 
+/// Pending renewal as defined here: (https://developer.apple.com/documentation/appstorereceipts/responsebody/pending_renewal_info)
+/// - Contains the pending renewal information for a ato-renewable subscription identified by the product_id.
+/// A pending renewal may refer to a renewal that the system scheduled in the future or a renewal that failed in the past for some reason.
+public struct PendingRenewalInfo: Codable {
+    
+    ///The key of the pending renewal array in the response body of the app store receipt
+    public static let KEY_IN_RESPONSE_BODY: String = "pending_renewal_info"
+    
+    ///The value for this key corresponds to the productIdentifier property of the product that the customer’s subscription renews.
+    public let autoRenewProductId: String
+    
+    ///The current renewal status for the auto-renewable subscription.
+    public let autoRenewStatus: AutoRenewStatus
+    
+    ///The reason a subscription expired. This field is only present for a receipt that contains an expired auto-renewable subscription.
+    public let expirationIntent: ExpirationIntent?
+    
+    ///The time at which the grace period for subscription renewals expires, in a date-time format similar to the ISO 8601.
+    public let gracePeriodExpiresDate: String?
+    
+    ///The time at which the grace period for subscription renewals expires, in UNIX epoch time format, in milliseconds.
+    ///This key is only present for apps that have Billing Grace Period enabled and when the user experiences a billing error at the time of renewal. Use this time format for processing dates.
+    public let gracePeriodExpiresDateMS: String?
+    
+    ///The time at which the grace period for subscription renewals expires, in the Pacific Time zone.
+    public let gracePeriodExpiresDatePST: String?
+    
+    ///A flag that indicates Apple is attempting to renew an expired subscription automatically. This field is only present if an auto-renewable subscription is in the billing retry state.
+    public let isInBillingRetryPeriod: BillingRetryPeriod?
+    
+    ///The reference name of a subscription offer that you configured in App Store Connect. This field is present when a customer redeemed a subscription offer code.
+    public let offerCodeRefName: String?
+    
+    ///The transaction identifier of the original purchase.
+    public let originalTransactionId: String
+    
+    ///The price consent status for a subscription price increase. This field is only present if the customer was notified of the price increase.
+    public let priceConsentStatus: PriceConsentStatus?
+    
+    ///The unique identifier of the product purchased. You provide this value when creating the product in App Store Connect,
+    ///and it corresponds to the productIdentifier property of the SKPayment object stored in the transaction's payment property.
+    public let productId: String
+    
+    ///The identifier of the promotional offer for an auto-renewable subscription that the user redeemed.
+    ///You provide this value in the Promotional Offer Identifier field when you create the promotional offer in App Store Connect.
+    public let promotionalOfferId: String?
+    
+    ///Convenience method to retrieve the expiry date of the grace period
+    public var gracePeriodExpiresDateMSToDate: Date? {
+        guard let string = gracePeriodExpiresDateMS else { return nil }
+        return Date(millisecondsSince1970: string)
+    }
+    
+    enum CodingKeys: String, CodingKey {
+        case autoRenewProductId = "auto_renew_product_id"
+        case autoRenewStatus = "auto_renew_status"
+        case expirationIntent = "expiration_intent"
+        case gracePeriodExpiresDate = "grace_period_expires_date"
+        case gracePeriodExpiresDateMS = "grace_period_expires_date_ms"
+        case gracePeriodExpiresDatePST = "grace_period_expires_date_pst"
+        case isInBillingRetryPeriod = "is_in_billing_retry_period"
+        case offerCodeRefName = "offer_code_ref_name"
+        case originalTransactionId = "original_transaction_id"
+        case priceConsentStatus = "price_consent_status"
+        case productId = "product_id"
+        case promotionalOfferId = "promotional_offer_id"
+    }
+    
+    ///Auto Renew Status defined here: (https://developer.apple.com/documentation/appstorereceipts/auto_renew_status)
+    public enum AutoRenewStatus: String, Codable {
+        ///The subscription will renew at the end of the current subscription period
+        case willRenew = "1"
+        ///The customer has turned off automatic renewal for the subscription.
+        case willNotRenew = "0"
+    }
+    
+    ///Expiration intent defined here: (https://developer.apple.com/documentation/appstorereceipts/auto_renew_status)
+    public enum ExpirationIntent: String, Codable {
+        ///The customer voluntarily canceled their subscription.
+        case subscriptionCanceled = "1"
+        ///Billing error; for example, the customer's payment information was no longer valid.
+        case billingError = "2"
+        ///The customer did not agree to a recent price increase.
+        case declinedPriceIncrease = "3"
+        ///The product was not available for purchase at the time of renewal.
+        case productWasUnavailable = "4"
+        ///Unknown error.
+        case unknown = "5"
+    }
+    
+    ///Billing Retry Period defined here: (https://developer.apple.com/documentation/appstorereceipts/is_in_billing_retry_period)
+    public enum BillingRetryPeriod: String, Codable {
+        ///The App Store is attempting to renew the subscription.
+        case isAttempting = "1"
+        ///The App Store has stopped attempting to renew the subscription.
+        case stoppedAttempting = "0"
+    }
+    
+    ///Documented as follows:
+    ///The default value is "0" and changes to "1" if the customer consents.
+    ///Possible values: 1, 0
+    public enum PriceConsentStatus: String, Codable {
+        case userDidNotConsent = "0"
+        case userDidConsent = "1"
+    }
+    
+    public init(autoRenewProductId: String, autoRenewStatus: AutoRenewStatus, expirationIntent: ExpirationIntent?, gracePeriodExpiresDate: String?, gracePeriodExpiresDateMS: String?, gracePeriodExpiresDatePST: String?, isInBillingRetryPeriod: BillingRetryPeriod?, offerCodeRefName: String?, originalTransactionId: String, priceConsentStatus: PriceConsentStatus?, productId: String, promotionalOfferId: String?) {
+        self.autoRenewProductId = autoRenewProductId
+        self.autoRenewStatus = autoRenewStatus
+        self.expirationIntent = expirationIntent
+        self.gracePeriodExpiresDate = gracePeriodExpiresDate
+        self.gracePeriodExpiresDateMS = gracePeriodExpiresDateMS
+        self.gracePeriodExpiresDatePST = gracePeriodExpiresDatePST
+        self.isInBillingRetryPeriod = isInBillingRetryPeriod
+        self.offerCodeRefName = offerCodeRefName
+        self.originalTransactionId = originalTransactionId
+        self.priceConsentStatus = priceConsentStatus
+        self.productId = productId
+        self.promotionalOfferId = promotionalOfferId
+    }
+}
+
 /// Status code returned by remote server
 /// Status code returned by remote server
 /// 
 /// 
 /// See Table 2-1  Status codes
 /// See Table 2-1  Status codes

+ 3 - 0
SwiftyStoreKit-iOS-Demo/ViewController.swift

@@ -344,6 +344,9 @@ extension ViewController {
         case .purchased(let expiryDate, let items):
         case .purchased(let expiryDate, let items):
             print("\(productIds) is valid until \(expiryDate)\n\(items)\n")
             print("\(productIds) is valid until \(expiryDate)\n\(items)\n")
             return alertWithTitle("Product is purchased", message: "Product is valid until \(expiryDate)")
             return alertWithTitle("Product is purchased", message: "Product is valid until \(expiryDate)")
+        case .inGracePeriod(let endDate, let items, let pendingRenewals):
+            print("\(productIds) is in grace period until \(endDate)\n\(items)\n\(pendingRenewals)\n")
+            return alertWithTitle("Product is purchased (grace period)", message: "Product is in grace period until \(endDate)")
         case .expired(let expiryDate, let items):
         case .expired(let expiryDate, let items):
             print("\(productIds) is expired since \(expiryDate)\n\(items)\n")
             print("\(productIds) is expired since \(expiryDate)\n\(items)\n")
             return alertWithTitle("Product expired", message: "Product is expired since \(expiryDate)")
             return alertWithTitle("Product expired", message: "Product is expired since \(expiryDate)")

+ 147 - 5
Tests/SwiftyStoreKitTests/InAppReceiptTests.swift

@@ -34,7 +34,7 @@ private extension TimeInterval {
 
 
 extension ReceiptItem: Equatable {
 extension ReceiptItem: Equatable {
 
 
-    init(productId: String, purchaseDate: Date, subscriptionExpirationDate: Date? = nil, cancellationDate: Date? = nil, isTrialPeriod: Bool = false, isInIntroOfferPeriod: Bool = false) {
+    init(productId: String, purchaseDate: Date, subscriptionExpirationDate: Date? = nil, cancellationDate: Date? = nil, transactionId: String? = nil, isTrialPeriod: Bool = false, isInIntroOfferPeriod: Bool = false) {
         self.init(productId: productId, quantity: 1, transactionId: UUID().uuidString, originalTransactionId: UUID().uuidString, purchaseDate: purchaseDate, originalPurchaseDate: purchaseDate, webOrderLineItemId: UUID().uuidString, subscriptionExpirationDate: subscriptionExpirationDate, cancellationDate: cancellationDate, isTrialPeriod: isTrialPeriod, isInIntroOfferPeriod: isInIntroOfferPeriod)
         self.init(productId: productId, quantity: 1, transactionId: UUID().uuidString, originalTransactionId: UUID().uuidString, purchaseDate: purchaseDate, originalPurchaseDate: purchaseDate, webOrderLineItemId: UUID().uuidString, subscriptionExpirationDate: subscriptionExpirationDate, cancellationDate: cancellationDate, isTrialPeriod: isTrialPeriod, isInIntroOfferPeriod: isInIntroOfferPeriod)
         self.productId = productId
         self.productId = productId
         self.quantity = 1
         self.quantity = 1
@@ -42,7 +42,7 @@ extension ReceiptItem: Equatable {
         self.originalPurchaseDate = purchaseDate
         self.originalPurchaseDate = purchaseDate
         self.subscriptionExpirationDate = subscriptionExpirationDate
         self.subscriptionExpirationDate = subscriptionExpirationDate
         self.cancellationDate = cancellationDate
         self.cancellationDate = cancellationDate
-        self.transactionId = UUID().uuidString
+        self.transactionId = transactionId ?? UUID().uuidString
         self.originalTransactionId = UUID().uuidString
         self.originalTransactionId = UUID().uuidString
         self.webOrderLineItemId = UUID().uuidString
         self.webOrderLineItemId = UUID().uuidString
         self.isTrialPeriod = isTrialPeriod
         self.isTrialPeriod = isTrialPeriod
@@ -81,6 +81,33 @@ extension ReceiptItem: Equatable {
     }
     }
 }
 }
 
 
+extension PendingRenewalInfo: Equatable {
+    init(productId: String, expiryDate: Date, originalTransactionId: String) {
+        self.init(autoRenewProductId: productId, autoRenewStatus: .willRenew, expirationIntent: nil, gracePeriodExpiresDate: nil, gracePeriodExpiresDateMS: expiryDate.timeIntervalSince1970.millisecondsNSString as String, gracePeriodExpiresDatePST: nil, isInBillingRetryPeriod: nil, offerCodeRefName: nil, originalTransactionId: originalTransactionId, priceConsentStatus: nil, productId: productId, promotionalOfferId: nil)
+    }
+    
+    var receiptInfo: NSDictionary {
+        var result: [String: AnyObject] = [
+            "auto_renew_product_id": productId as NSString,
+            "auto_renew_status": autoRenewStatus.rawValue as NSString,
+            "product_id": productId as NSString,
+            "original_transaction_id": originalTransactionId as NSString
+        ]
+        if let gracePeriodExpiresDateMS = gracePeriodExpiresDateMS {
+            result["grace_period_expires_date_ms"] = gracePeriodExpiresDateMS as NSString
+        }
+        return NSDictionary(dictionary: result)
+    }
+    
+    public static func == (lhs: PendingRenewalInfo, rhs: PendingRenewalInfo) -> Bool {
+        return
+            lhs.productId == rhs.productId &&
+            lhs.autoRenewProductId == rhs.autoRenewProductId &&
+            lhs.originalTransactionId == rhs.originalTransactionId &&
+            lhs.gracePeriodExpiresDateMS == rhs.gracePeriodExpiresDateMS
+    }
+}
+
 extension VerifySubscriptionResult: Equatable {
 extension VerifySubscriptionResult: Equatable {
 
 
     public static func == (lhs: VerifySubscriptionResult, rhs: VerifySubscriptionResult) -> Bool {
     public static func == (lhs: VerifySubscriptionResult, rhs: VerifySubscriptionResult) -> Bool {
@@ -90,6 +117,8 @@ extension VerifySubscriptionResult: Equatable {
             return lhsExpiryDate == rhsExpiryDate && lhsReceiptItem == rhsReceiptItem
             return lhsExpiryDate == rhsExpiryDate && lhsReceiptItem == rhsReceiptItem
         case (.expired(let lhsExpiryDate, let lhsReceiptItem), .expired(let rhsExpiryDate, let rhsReceiptItem)):
         case (.expired(let lhsExpiryDate, let lhsReceiptItem), .expired(let rhsExpiryDate, let rhsReceiptItem)):
             return lhsExpiryDate == rhsExpiryDate && lhsReceiptItem == rhsReceiptItem
             return lhsExpiryDate == rhsExpiryDate && lhsReceiptItem == rhsReceiptItem
+        case (.inGracePeriod(let lhsEndDate, let lhsReceiptItem, let lhsRenewals), .inGracePeriod(let rhsEndDate, let rhsReceiptItem, let rhsRenewals)):
+            return lhsEndDate == rhsEndDate && lhsReceiptItem == rhsReceiptItem && lhsRenewals == rhsRenewals
         default: return false
         default: return false
         }
         }
     }
     }
@@ -204,6 +233,56 @@ class InAppReceiptTests: XCTestCase {
         let expectedSubscriptionResult = VerifySubscriptionResult.notPurchased
         let expectedSubscriptionResult = VerifySubscriptionResult.notPurchased
         XCTAssertEqual(verifySubscriptionResult, expectedSubscriptionResult)
         XCTAssertEqual(verifySubscriptionResult, expectedSubscriptionResult)
     }
     }
+    
+    // auto-renewable, in grace period
+    func testVerifyAutoRenewableSubscription_when_oneGracePeriodSubscription_then_resultIsPurchased() {
+        let receiptRequestDate = makeDateAtMidnight(year: 2017, month: 5, day: 15)
+        let productId = "product1"
+        let purchaseDate = makeDateAtMidnight(year: 2017, month: 5, day: 14)
+        let expirationDate = purchaseDate.addingTimeInterval(60 * 60)
+        let transactionId = UUID().uuidString
+        let item = ReceiptItem(productId: productId, purchaseDate: purchaseDate, subscriptionExpirationDate: expirationDate, cancellationDate: nil, transactionId: transactionId, isTrialPeriod: false)
+        
+        let gracePeriodExpirationDate = makeDateAtMidnight(year: 2017, month: 5, day: 16)
+        let pendingRenewal = PendingRenewalInfo(productId: productId, expiryDate: gracePeriodExpirationDate, originalTransactionId: transactionId)
+        
+        let receiptNormal = makeReceipt(items: [item], requestDate: receiptRequestDate)
+        let verifySubscriptionResultNormal = SwiftyStoreKit.verifySubscription(ofType: .autoRenewable, productId: productId, inReceipt: receiptNormal)
+        let expectedSubscriptionResultNormal = VerifySubscriptionResult.expired(expiryDate: expirationDate, items: [item])
+        //Sanity Check: Without the pending renewal info the receipt should have been expired.
+        XCTAssertEqual(verifySubscriptionResultNormal, expectedSubscriptionResultNormal)
+        
+        let receiptWithPendingRenewal = makeReceipt(items: [item], requestDate: receiptRequestDate, pendingRenewals: [pendingRenewal])
+        let verifySubscriptionResultWithPendingRenewal = SwiftyStoreKit.verifySubscription(ofType: .autoRenewable, productId: productId, inReceipt: receiptWithPendingRenewal)
+        let expectedSubscriptionResultWithPendingRenewal = VerifySubscriptionResult.inGracePeriod(endDate: gracePeriodExpirationDate, items: [item], pendingRenewals: [pendingRenewal])
+        //With the pending renewal info, we're in a grace period
+        XCTAssertEqual(verifySubscriptionResultWithPendingRenewal, expectedSubscriptionResultWithPendingRenewal)
+    }
+    
+    // auto-renewable, in expired grace period
+    func testVerifyAutoRenewableSubscription_when_oneExpiredGracePeriodSubscription_then_resultIsExpired() {
+        let receiptRequestDate = makeDateAtMidnight(year: 2017, month: 5, day: 20)
+        let productId = "product1"
+        let purchaseDate = makeDateAtMidnight(year: 2017, month: 5, day: 14)
+        let expirationDate = purchaseDate.addingTimeInterval(60 * 60)
+        let transactionId = UUID().uuidString
+        let item = ReceiptItem(productId: productId, purchaseDate: purchaseDate, subscriptionExpirationDate: expirationDate, cancellationDate: nil, transactionId: transactionId, isTrialPeriod: false)
+        
+        let gracePeriodExpirationDate = makeDateAtMidnight(year: 2017, month: 5, day: 19)
+        let pendingRenewal = PendingRenewalInfo(productId: productId, expiryDate: gracePeriodExpirationDate, originalTransactionId: transactionId)
+        
+        let receiptNormal = makeReceipt(items: [item], requestDate: receiptRequestDate)
+        let verifySubscriptionResultNormal = SwiftyStoreKit.verifySubscription(ofType: .autoRenewable, productId: productId, inReceipt: receiptNormal)
+        let expectedSubscriptionResultNormal = VerifySubscriptionResult.expired(expiryDate: expirationDate, items: [item])
+        //Sanity Check: Without the pending renewal info the receipt should have been expired.
+        XCTAssertEqual(verifySubscriptionResultNormal, expectedSubscriptionResultNormal)
+        
+        let receiptWithPendingRenewal = makeReceipt(items: [item], requestDate: receiptRequestDate, pendingRenewals: [pendingRenewal])
+        let verifySubscriptionResultWithPendingRenewal = SwiftyStoreKit.verifySubscription(ofType: .autoRenewable, productId: productId, inReceipt: receiptWithPendingRenewal)
+        let expectedSubscriptionResultWithPendingRenewal = VerifySubscriptionResult.expired(expiryDate: expirationDate, items: [item])
+        //With the pending renewal info, we're still in the expired state as the pending renewal info has expired as well
+        XCTAssertEqual(verifySubscriptionResultWithPendingRenewal, expectedSubscriptionResultWithPendingRenewal)
+    }
 
 
     // non-renewing, non purchased
     // non-renewing, non purchased
     func testVerifyNonRenewingSubscription_when_noSubscriptions_then_resultIsNotPurchased() {
     func testVerifyNonRenewingSubscription_when_noSubscriptions_then_resultIsNotPurchased() {
@@ -372,6 +451,65 @@ class InAppReceiptTests: XCTestCase {
         XCTAssertEqual(verifySubscriptionResult, expectedSubscriptionResult)
         XCTAssertEqual(verifySubscriptionResult, expectedSubscriptionResult)
     }
     }
     
     
+    func testVerifyAutoRenewableSubscriptions_when_threeSubscriptions_oneInGracePeriodExpired_twoInGracePeriod_then_resultIsPurchased_itemsSorted() {
+        let receiptRequestDate = makeDateAtMidnight(year: 2017, month: 5, day: 20)
+        
+        let productId1 = "product1"
+        let productId2 = "product2"
+        let productId3 = "product3"
+        let productIds = Set([ productId1, productId2, productId3 ])
+        let isTrialPeriod = false
+        
+        let id1 = UUID().uuidString
+        let id2 = UUID().uuidString
+        let id3 = UUID().uuidString
+        
+        let purchaseDate1 = makeDateAtMidnight(year: 2017, month: 5, day: 10)
+        let expirationDate1 = purchaseDate1.addingTimeInterval(60 * 60)
+        let item1 = ReceiptItem(productId: productId1,
+                                    purchaseDate: purchaseDate1,
+                                    subscriptionExpirationDate: expirationDate1,
+                                    transactionId: id1,
+                                    isTrialPeriod: isTrialPeriod)
+        
+        let purchaseDate2 = makeDateAtMidnight(year: 2017, month: 5, day: 11)
+        let expirationDate2 = purchaseDate2.addingTimeInterval(60 * 60)
+        let item2 = ReceiptItem(productId: productId2,
+                                    purchaseDate: purchaseDate2,
+                                    subscriptionExpirationDate: expirationDate2,
+                                    transactionId: id2,
+                                    isTrialPeriod: isTrialPeriod)
+        
+        let purchaseDate3 = makeDateAtMidnight(year: 2017, month: 5, day: 12)
+        let expirationDate3 = purchaseDate3.addingTimeInterval(60 * 60)
+        let item3 = ReceiptItem(productId: productId3,
+                                    purchaseDate: purchaseDate3,
+                                    subscriptionExpirationDate: expirationDate3,
+                                    transactionId: id3,
+                                    isTrialPeriod: isTrialPeriod)
+        
+        //Sanity Check: Without pending renewals the result should be expired, and items ordered descending
+        let receipt = makeReceipt(items: [item1, item2, item3], requestDate: receiptRequestDate)
+        let verifySubscriptionResult = SwiftyStoreKit.verifySubscriptions(ofType: .autoRenewable, productIds: productIds, inReceipt: receipt)
+        let expectedSubscriptionResult = VerifySubscriptionResult.expired(expiryDate: expirationDate3, items: [item3, item2, item1])
+        XCTAssertEqual(verifySubscriptionResult, expectedSubscriptionResult)
+        
+        let renewalDate1 = makeDateAtMidnight(year: 2017, month: 5, day: 19)
+        let renewalDate2 = makeDateAtMidnight(year: 2017, month: 5, day: 21)
+        let renewalDate3 = makeDateAtMidnight(year: 2017, month: 5, day: 22)
+        
+        let renewal1 = PendingRenewalInfo(productId: productId1, expiryDate: renewalDate1, originalTransactionId: id1)
+        let renewal2 = PendingRenewalInfo(productId: productId2, expiryDate: renewalDate2, originalTransactionId: id2)
+        let renewal3 = PendingRenewalInfo(productId: productId3, expiryDate: renewalDate3, originalTransactionId: id3)
+        
+        //With pending renewals here, renewal1 and thus item 2 should be expired and not returned.
+        //But the result should be `.inGracePeriod` with the items/renewals in descending order.
+        let receiptWithRenewables = makeReceipt(items: [item1, item2, item3], requestDate: receiptRequestDate, pendingRenewals: [renewal1, renewal2, renewal3])
+        let verifySubscriptionResultRenewables = SwiftyStoreKit.verifySubscriptions(ofType: .autoRenewable, productIds: productIds, inReceipt: receiptWithRenewables)
+        let expectedSubscriptionResultRenewables = VerifySubscriptionResult.inGracePeriod(endDate: renewalDate3, items: [item3, item2], pendingRenewals: [renewal3, renewal2])
+        XCTAssertEqual(verifySubscriptionResultRenewables, expectedSubscriptionResultRenewables)
+    }
+    
     // MARK: Get Distinct Purchase Identifiers, empty receipt item tests
     // MARK: Get Distinct Purchase Identifiers, empty receipt item tests
     func testGetDistinctPurchaseIds_when_noReceipt_then_resultIsNil() {
     func testGetDistinctPurchaseIds_when_noReceipt_then_resultIsNil() {
         let receiptRequestDate = makeDateAtMidnight(year: 2017, month: 5, day: 14)
         let receiptRequestDate = makeDateAtMidnight(year: 2017, month: 5, day: 14)
@@ -426,22 +564,26 @@ class InAppReceiptTests: XCTestCase {
     }
     }
 
 
     // MARK: Helper methods
     // MARK: Helper methods
-    func makeReceipt(items: [ReceiptItem], requestDate: Date) -> [String: AnyObject] {
+    func makeReceipt(items: [ReceiptItem], requestDate: Date, pendingRenewals: [PendingRenewalInfo] = []) -> [String: AnyObject] {
         let receiptInfos = items.map { $0.receiptInfo }
         let receiptInfos = items.map { $0.receiptInfo }
+        let renewalReceiptInfos = pendingRenewals.map { $0.receiptInfo }
 
 
         // Creating this with NSArray results in __NSSingleObjectArrayI which fails the cast to [String: AnyObject]
         // Creating this with NSArray results in __NSSingleObjectArrayI which fails the cast to [String: AnyObject]
         let array = NSMutableArray()
         let array = NSMutableArray()
         array.addObjects(from: receiptInfos)
         array.addObjects(from: receiptInfos)
+        
+        let arrayRenewables = NSMutableArray()
+        arrayRenewables.addObjects(from: renewalReceiptInfos)
 
 
         return [
         return [
-            //"latest_receipt": [:],
             "status": "200" as NSString,
             "status": "200" as NSString,
             "environment": "Sandbox" as NSString,
             "environment": "Sandbox" as NSString,
             "receipt": NSDictionary(dictionary: [
             "receipt": NSDictionary(dictionary: [
                 "request_date_ms": requestDate.timeIntervalSince1970.millisecondsNSString,
                 "request_date_ms": requestDate.timeIntervalSince1970.millisecondsNSString,
                 "in_app": array // non renewing
                 "in_app": array // non renewing
             ]),
             ]),
-            "latest_receipt_info": array // autoRenewable
+            "latest_receipt_info": array, // autoRenewable
+            PendingRenewalInfo.KEY_IN_RESPONSE_BODY: arrayRenewables //autoRenewable in case of grace period active
         ]
         ]
     }
     }