Bläddra i källkod

Merge pull request #213 from bizz84/feature/verify-receipt-auto-refresh

Feature/verify receipt auto refresh
Andrea Bizzotto 8 år sedan
förälder
incheckning
85ac45c07f

+ 42 - 23
README.md

@@ -201,6 +201,28 @@ According to [Apple - Delivering Products](https://developer.apple.com/library/c
 
 > Information about all other kinds of purchases is added to the receipt when they’re paid for and remains in the receipt indefinitely.
 
+When an app is first installed, the app receipt is missing.
+
+As soon as a user completes a purchase or restores purchases, StoreKit creates and stores the receipt locally as a file.
+
+As the local receipt is always encrypted, a verification step is needed to get all the receipt fields in readable form.
+
+This is done with a `verifyReceipt` method which does two things:
+
+- If the receipt is missing, refresh it
+- If the receipt is available or is refreshed, validate it
+
+Receipt validation can be done remotely with Apple via the `AppleReceiptValidator` class, or with a client-supplied validator conforming to the `ReceiptValidator` protocol. 
+
+**Notes**
+
+* If the local receipt is missing when calling `verifyReceipt`, a network call is made to refresh it.
+* If the user is not logged to the App Store, StoreKit will present a popup asking to **Sign In to the iTunes Store**.
+* If the user enters valid credentials, the receipt will be refreshed and verified.
+* If the user cancels, receipt refresh will fail with a **Cannot connect to iTunes Store** error.
+* Using `AppleReceiptValidator` (see below) does remote receipt validation and also results in a network call.
+* Local receipt validation is not implemented (see [issue #101](https://github.com/bizz84/SwiftyStoreKit/issues/101) for details).
+
 
 ### Retrieve local receipt
 
@@ -215,31 +237,25 @@ let receiptString = receiptData.base64EncodedString(options: [])
 ```swift
 let appleValidator = AppleReceiptValidator(service: .production)
 SwiftyStoreKit.verifyReceipt(using: appleValidator, password: "your-shared-secret") { result in
-    if case .error(let error) = result {
-        if case .noReceiptData = error {
-            self.refreshReceipt()
-        }
-    }
-}
-
-func refreshReceipt() {
-    SwiftyStoreKit.refreshReceipt { result in
-        switch result {
-        case .success(let receiptData):
-            print("Receipt refresh success: \(receiptData.base64EncodedString)")
-        case .error(let error):
-            print("Receipt refresh failed: \(error)")
-        }
-    }
+    switch result {
+    case .success(let receipt):
+        print("Verify receipt Success: \(receipt)")
+    case .error(let error):
+        print("Verify receipt Failed: \(error)")
+	}
 }
 ```
 
-#### Notes
+## Verifying purchases and subscriptions
 
-* If the user is not logged to iTunes when `refreshReceipt` is called, StoreKit will present a popup asking to **Sign In to the iTunes Store**.
-* If the user enters valid credentials, the receipt will be refreshed.
-* If the user cancels, receipt refresh will fail with a **Cannot connect to iTunes Store** error.
+Once you have retrieved the receipt using the `verifyReceipt` method, you can verify your purchases and subscriptions by product identifier.
+
+Verifying multiple purchases and subscriptions in one call is not yet supported (see [issue #194](https://github.com/bizz84/SwiftyStoreKit/issues/194) for more details).
 
+If you need to verify multiple purchases / subscriptions, you can either:
+
+* manually parse the receipt dictionary returned by `verifyReceipt`
+* call `verifyPurchase` or `verifySubscription` multiple times with different product identifiers
 
 ### Verify Purchase
 
@@ -304,7 +320,7 @@ SwiftyStoreKit.verifyReceipt(using: appleValidator, password: "your-shared-secre
 ```
 
 #### Auto-Renewable
-```
+```swift
 let purchaseResult = SwiftyStoreKit.verifySubscription(
             type: .autoRenewable,
             productId: "com.musevisions.SwiftyStoreKit.Subscription",
@@ -312,7 +328,7 @@ let purchaseResult = SwiftyStoreKit.verifySubscription(
 ```
 
 #### Non-Renewing
-```
+```swift
 // validDuration: time interval in seconds
 let purchaseResult = SwiftyStoreKit.verifySubscription(
             type: .nonRenewing(validDuration: 3600 * 24 * 30),
@@ -320,7 +336,10 @@ let purchaseResult = SwiftyStoreKit.verifySubscription(
             inReceipt: receipt)
 ```
 
-**Note**: When purchasing subscriptions in sandbox mode, the expiry dates are set just minutes after the purchase date for testing purposes.
+**Notes**
+
+* The expiration dates are calculated against the receipt date. This is the date of the last successful call to `verifyReceipt`.
+* When purchasing subscriptions in sandbox mode, the expiry dates are set just minutes after the purchase date for testing purposes.
 
 #### Purchasing and verifying a subscription 
 

+ 17 - 40
SwiftyStoreKit-iOS-Demo/ViewController.swift

@@ -62,7 +62,7 @@ class ViewController: UIViewController {
     @IBAction func verifyPurchase2() {
         verifyPurchase(purchase2Suffix)
     }
-
+    
     func getInfo(_ purchase: RegisteredPurchase) {
 
         NetworkActivityIndicatorManager.networkOperationStarted()
@@ -108,25 +108,23 @@ class ViewController: UIViewController {
     @IBAction func verifyReceipt() {
 
         NetworkActivityIndicatorManager.networkOperationStarted()
-		let appleValidator = AppleReceiptValidator(service: .production)
-		SwiftyStoreKit.verifyReceipt(using: appleValidator, password: "your-shared-secret") { result in
+        verifyReceipt { result in
             NetworkActivityIndicatorManager.networkOperationFinished()
-
             self.showAlert(self.alertForVerifyReceipt(result))
-
-            if case .error(let error) = result {
-                if case .noReceiptData = error {
-                    self.refreshReceipt()
-                }
-            }
         }
     }
+    
+    func verifyReceipt(completion: @escaping (VerifyReceiptResult) -> Void) {
+        
+        let appleValidator = AppleReceiptValidator(service: .production)
+        let password = "your-shared-secret"
+        SwiftyStoreKit.verifyReceipt(using: appleValidator, password: password, completion: completion)
+    }
 
     func verifyPurchase(_ purchase: RegisteredPurchase) {
 
         NetworkActivityIndicatorManager.networkOperationStarted()
-		let appleValidator = AppleReceiptValidator(service: .production)
-		SwiftyStoreKit.verifyReceipt(using: appleValidator, password: "your-shared-secret") { result in
+        verifyReceipt { result in
             NetworkActivityIndicatorManager.networkOperationFinished()
 
             switch result {
@@ -159,23 +157,12 @@ class ViewController: UIViewController {
                     self.showAlert(self.alertForVerifyPurchase(purchaseResult))
                 }
 
-            case .error(let error):
+            case .error:
                 self.showAlert(self.alertForVerifyReceipt(result))
-                if case .noReceiptData = error {
-                    self.refreshReceipt()
-                }
             }
         }
     }
 
-    func refreshReceipt() {
-
-        SwiftyStoreKit.refreshReceipt { result in
-
-            self.showAlert(self.alertForRefreshReceipt(result))
-        }
-    }
-
 #if os(iOS)
     override var preferredStatusBarStyle: UIStatusBarStyle {
         return .lightContent
@@ -262,14 +249,16 @@ extension ViewController {
         switch result {
         case .success(let receipt):
             print("Verify receipt Success: \(receipt)")
-            return alertWithTitle("Receipt verified", message: "Receipt verified remotly")
+            return alertWithTitle("Receipt verified", message: "Receipt verified remotely")
         case .error(let error):
             print("Verify receipt Failed: \(error)")
             switch error {
-            case .noReceiptData :
-                return alertWithTitle("Receipt verification", message: "No receipt data, application will try to get a new one. Try again.")
+            case .noReceiptData:
+                return alertWithTitle("Receipt verification", message: "No receipt data. Try again.")
+            case .networkError(let error):
+                return alertWithTitle("Receipt verification", message: "Network error while verifying receipt: \(error)")
             default:
-                return alertWithTitle("Receipt verification", message: "Receipt verification failed")
+                return alertWithTitle("Receipt verification", message: "Receipt verification failed: \(error)")
             }
         }
     }
@@ -300,16 +289,4 @@ extension ViewController {
             return alertWithTitle("Not purchased", message: "This product has never been purchased")
         }
     }
-
-    func alertForRefreshReceipt(_ result: RefreshReceiptResult) -> UIAlertController {
-        switch result {
-        case .success(let receiptData):
-            print("Receipt refresh Success: \(receiptData.base64EncodedString)")
-            return alertWithTitle("Receipt refreshed", message: "Receipt refreshed successfully")
-        case .error(let error):
-            print("Receipt refresh Failed: \(error)")
-            return alertWithTitle("Receipt refresh failed", message: "Receipt refresh failed")
-        }
-    }
-
 }

+ 19 - 36
SwiftyStoreKit-macOS-Demo/ViewController.swift

@@ -105,24 +105,21 @@ class ViewController: NSViewController {
 
     @IBAction func verifyReceipt(_ sender: Any?) {
 
+        verifyReceipt(completion: { result in
+            self.showAlert(self.alertForVerifyReceipt(result))
+        })
+    }
+    
+    func verifyReceipt(completion: @escaping (VerifyReceiptResult) -> Void) {
+        
         let appleValidator = AppleReceiptValidator(service: .production)
-        SwiftyStoreKit.verifyReceipt(using: appleValidator, password: "your-shared-secret") { result in
-
-            self.showAlert(self.alertForVerifyReceipt(result)) { _ in
-
-                if case .error(let error) = result {
-                    if case .noReceiptData = error {
-                        self.refreshReceipt()
-                    }
-                }
-            }
-        }
+        let password = "your-shared-secret"
+        SwiftyStoreKit.verifyReceipt(using: appleValidator, password: password, completion: completion)
     }
 
     func verifyPurchase(_ purchase: RegisteredPurchase) {
 
-        let appleValidator = AppleReceiptValidator(service: .production)
-        SwiftyStoreKit.verifyReceipt(using: appleValidator, password: "your-shared-secret") { result in
+        verifyReceipt { result in
 
             switch result {
             case .success(let receipt):
@@ -157,15 +154,6 @@ class ViewController: NSViewController {
             }
         }
     }
-
-    func refreshReceipt() {
-
-        SwiftyStoreKit.refreshReceipt { result in
-
-            self.showAlert(self.alertForRefreshReceipt(result))
-        }
-    }
-
 }
 
 // MARK: User facing alerts
@@ -244,10 +232,17 @@ extension ViewController {
         switch result {
         case .success(let receipt):
             print("Verify receipt Success: \(receipt)")
-            return self.alertWithTitle("Receipt verified", message: "Receipt verified remotly")
+            return self.alertWithTitle("Receipt verified", message: "Receipt verified remotely")
         case .error(let error):
             print("Verify receipt Failed: \(error)")
-            return self.alertWithTitle("Receipt verification failed", message: "The application will exit to create receipt data. You must have signed the application with your developer id to test and be outside of XCode")
+            switch error {
+            case .noReceiptData:
+                return alertWithTitle("Receipt verification", message: "No receipt data. Try again.")
+            case .networkError(let error):
+                return alertWithTitle("Receipt verification", message: "Network error while verifying receipt: \(error)")
+            default:
+                return alertWithTitle("Receipt verification", message: "Receipt verification failed: \(error)")
+            }
         }
     }
 
@@ -277,16 +272,4 @@ extension ViewController {
             return alertWithTitle("Not purchased", message: "This product has never been purchased")
         }
     }
-
-    func alertForRefreshReceipt(_ result: RefreshReceiptResult) -> NSAlert {
-        switch result {
-        case .success(let receiptData):
-            print("Receipt refresh Success: \(receiptData.base64EncodedString)")
-            return alertWithTitle("Receipt refreshed", message: "Receipt refreshed successfully")
-        case .error(let error):
-            print("Receipt refresh Failed: \(error)")
-            return alertWithTitle("Receipt refresh failed", message: "Receipt refresh failed")
-        }
-    }
-
 }

+ 12 - 0
SwiftyStoreKit.xcodeproj/project.pbxproj

@@ -47,6 +47,10 @@
 		65BB6CE81DDB018900218A0B /* SwiftyStoreKit+Types.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65BB6CE71DDB018900218A0B /* SwiftyStoreKit+Types.swift */; };
 		65BB6CE91DDB018900218A0B /* SwiftyStoreKit+Types.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65BB6CE71DDB018900218A0B /* SwiftyStoreKit+Types.swift */; };
 		65BB6CEA1DDB018900218A0B /* SwiftyStoreKit+Types.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65BB6CE71DDB018900218A0B /* SwiftyStoreKit+Types.swift */; };
+		65CEF0F41ECC80D9007DC3B6 /* InAppReceiptVerificatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65CEF0F31ECC80D9007DC3B6 /* InAppReceiptVerificatorTests.swift */; };
+		65E9E0791ECADF5E005CF7B4 /* InAppReceiptVerificator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E9E0781ECADF5E005CF7B4 /* InAppReceiptVerificator.swift */; };
+		65E9E07A1ECADF5E005CF7B4 /* InAppReceiptVerificator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E9E0781ECADF5E005CF7B4 /* InAppReceiptVerificator.swift */; };
+		65E9E07B1ECADF5E005CF7B4 /* InAppReceiptVerificator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E9E0781ECADF5E005CF7B4 /* InAppReceiptVerificator.swift */; };
 		65F70AC71E2ECBB300BF040D /* PaymentTransactionObserverFake.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F70AC61E2ECBB300BF040D /* PaymentTransactionObserverFake.swift */; };
 		65F70AC91E2EDC3700BF040D /* PaymentsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F70AC81E2EDC3700BF040D /* PaymentsController.swift */; };
 		65F70ACA1E2EDC3700BF040D /* PaymentsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F70AC81E2EDC3700BF040D /* PaymentsController.swift */; };
@@ -179,6 +183,8 @@
 		658A084B1E2EC5960074A98F /* PaymentQueueSpy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaymentQueueSpy.swift; sourceTree = "<group>"; };
 		65B8C9281EC0BE62009439D9 /* InAppReceiptTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppReceiptTests.swift; sourceTree = "<group>"; };
 		65BB6CE71DDB018900218A0B /* SwiftyStoreKit+Types.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SwiftyStoreKit+Types.swift"; sourceTree = "<group>"; };
+		65CEF0F31ECC80D9007DC3B6 /* InAppReceiptVerificatorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppReceiptVerificatorTests.swift; sourceTree = "<group>"; };
+		65E9E0781ECADF5E005CF7B4 /* InAppReceiptVerificator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppReceiptVerificator.swift; sourceTree = "<group>"; };
 		65F70AC61E2ECBB300BF040D /* PaymentTransactionObserverFake.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaymentTransactionObserverFake.swift; sourceTree = "<group>"; };
 		65F70AC81E2EDC3700BF040D /* PaymentsController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaymentsController.swift; sourceTree = "<group>"; };
 		65F7DF681DCD4DF000835D30 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
@@ -306,6 +312,7 @@
 				650307F71E317BCF001332A4 /* CompleteTransactionsController.swift */,
 				C4083C561C2AB0A900295248 /* InAppReceiptRefreshRequest.swift */,
 				C4A7C7621C29B8D00053ED64 /* InAppReceipt.swift */,
+				65E9E0781ECADF5E005CF7B4 /* InAppReceiptVerificator.swift */,
 				1592CD4F1E27756500D321E6 /* AppleReceiptValidator.swift */,
 				653722801DB8282600C8F944 /* SKProduct+LocalizedPrice.swift */,
 				C40C680F1C29414C00B60B7E /* OS.swift */,
@@ -333,6 +340,7 @@
 				650307F11E3163AA001332A4 /* RestorePurchasesControllerTests.swift */,
 				C3099C181E3206C700392A54 /* CompleteTransactionsControllerTests.swift */,
 				65B8C9281EC0BE62009439D9 /* InAppReceiptTests.swift */,
+				65CEF0F31ECC80D9007DC3B6 /* InAppReceiptVerificatorTests.swift */,
 				658A084B1E2EC5960074A98F /* PaymentQueueSpy.swift */,
 				65F70AC61E2ECBB300BF040D /* PaymentTransactionObserverFake.swift */,
 				C3099C081E2FCE3A00392A54 /* TestProduct.swift */,
@@ -710,6 +718,7 @@
 				650307FE1E33154F001332A4 /* ProductsInfoController.swift in Sources */,
 				650307F61E3177EF001332A4 /* RestorePurchasesController.swift in Sources */,
 				658A08391E2EC24E0074A98F /* PaymentQueueController.swift in Sources */,
+				65E9E07B1ECADF5E005CF7B4 /* InAppReceiptVerificator.swift in Sources */,
 				653722831DB8290B00C8F944 /* SKProduct+LocalizedPrice.swift in Sources */,
 				54B069921CF742D100BAFE38 /* InAppReceipt.swift in Sources */,
 				650307FA1E317BCF001332A4 /* CompleteTransactionsController.swift in Sources */,
@@ -740,6 +749,7 @@
 				650307FC1E33154F001332A4 /* ProductsInfoController.swift in Sources */,
 				650307F41E3177EF001332A4 /* RestorePurchasesController.swift in Sources */,
 				658A08371E2EC24E0074A98F /* PaymentQueueController.swift in Sources */,
+				65E9E0791ECADF5E005CF7B4 /* InAppReceiptVerificator.swift in Sources */,
 				653722811DB8282600C8F944 /* SKProduct+LocalizedPrice.swift in Sources */,
 				C4A7C7631C29B8D00053ED64 /* InAppReceipt.swift in Sources */,
 				650307F81E317BCF001332A4 /* CompleteTransactionsController.swift in Sources */,
@@ -764,6 +774,7 @@
 			files = (
 				C3099C071E2FCDAA00392A54 /* PaymentsControllerTests.swift in Sources */,
 				650307F21E3163AA001332A4 /* RestorePurchasesControllerTests.swift in Sources */,
+				65CEF0F41ECC80D9007DC3B6 /* InAppReceiptVerificatorTests.swift in Sources */,
 				C3099C0B1E2FD13200392A54 /* TestPaymentTransaction.swift in Sources */,
 				65F70AC71E2ECBB300BF040D /* PaymentTransactionObserverFake.swift in Sources */,
 				658A084A1E2EC5350074A98F /* PaymentQueueControllerTests.swift in Sources */,
@@ -786,6 +797,7 @@
 				650307FD1E33154F001332A4 /* ProductsInfoController.swift in Sources */,
 				650307F51E3177EF001332A4 /* RestorePurchasesController.swift in Sources */,
 				658A08381E2EC24E0074A98F /* PaymentQueueController.swift in Sources */,
+				65E9E07A1ECADF5E005CF7B4 /* InAppReceiptVerificator.swift in Sources */,
 				653722821DB8290A00C8F944 /* SKProduct+LocalizedPrice.swift in Sources */,
 				C4083C551C2AADB500295248 /* InAppReceipt.swift in Sources */,
 				650307F91E317BCF001332A4 /* CompleteTransactionsController.swift in Sources */,

+ 2 - 0
SwiftyStoreKit/AppleReceiptValidator.swift

@@ -25,6 +25,8 @@
 
 import Foundation
 
+// https://developer.apple.com/library/ios/releasenotes/General/ValidateAppStoreReceipt/Chapters/ValidateRemotely.html
+
 public struct AppleReceiptValidator: ReceiptValidator {
 
 	public enum VerifyReceiptURLType: String {

+ 0 - 37
SwiftyStoreKit/InAppReceipt.swift

@@ -80,43 +80,6 @@ extension ReceiptItem {
 // MARK - receipt mangement
 internal class InAppReceipt {
 
-    static var appStoreReceiptUrl: URL? {
-        return Bundle.main.appStoreReceiptURL
-    }
-
-    static var appStoreReceiptData: Data? {
-        guard let receiptDataURL = appStoreReceiptUrl, let data = try? Data(contentsOf: receiptDataURL) else {
-            return nil
-        }
-        return data
-    }
-
-    // The base64 encoded receipt data.
-    static var appStoreReceiptBase64Encoded: String? {
-        return appStoreReceiptData?.base64EncodedString(options: [])
-    }
-
-    // https://developer.apple.com/library/ios/releasenotes/General/ValidateAppStoreReceipt/Chapters/ValidateRemotely.html
-
-    /**
-     *  - Parameter receiptVerifyURL: receipt verify url (default: Production)
-     *  - Parameter password: Only used for receipts that contain auto-renewable subscriptions. Your app’s shared secret (a hexadecimal string).
-     *  - Parameter session: the session used to make remote call.
-     *  - Parameter completion: handler for result
-     */
-    class func verify(using validator: ReceiptValidator,
-                      password autoRenewPassword: String? = nil,
-                      completion: @escaping (VerifyReceiptResult) -> Void) {
-
-        // If no receipt is present, validation fails.
-        guard let base64EncodedString = appStoreReceiptBase64Encoded else {
-            completion(.error(error: .noReceiptData))
-            return
-        }
-
-        validator.validate(receipt: base64EncodedString, password: autoRenewPassword, completion: completion)
-    }
-
     /**
      *  Verify the purchase of a Consumable or NonConsumable product in a receipt
      *  - Parameter productId: the product id of the purchase to verify

+ 2 - 1
SwiftyStoreKit/InAppReceiptRefreshRequest.swift

@@ -34,6 +34,7 @@ class InAppReceiptRefreshRequest: NSObject, SKRequestDelegate {
     }
 
     typealias RequestCallback = (ResultType) -> Void
+    typealias ReceiptRefresh = (_ receiptProperties: [String : Any]?, _ callback: @escaping RequestCallback) -> InAppReceiptRefreshRequest
 
     class func refresh(_ receiptProperties: [String : Any]? = nil, callback: @escaping RequestCallback) -> InAppReceiptRefreshRequest {
         let request = InAppReceiptRefreshRequest(receiptProperties: receiptProperties, callback: callback)
@@ -48,7 +49,7 @@ class InAppReceiptRefreshRequest: NSObject, SKRequestDelegate {
         refreshReceiptRequest.delegate = nil
     }
 
-    private init(receiptProperties: [String : Any]? = nil, callback: @escaping RequestCallback) {
+    init(receiptProperties: [String : Any]? = nil, callback: @escaping RequestCallback) {
         self.callback = callback
         self.refreshReceiptRequest = SKReceiptRefreshRequest(receiptProperties: receiptProperties)
         super.init()

+ 100 - 0
SwiftyStoreKit/InAppReceiptVerificator.swift

@@ -0,0 +1,100 @@
+//
+//  InAppReceiptVerificator.swift
+//  SwiftyStoreKit
+//
+//  Created by Andrea Bizzotto on 16/05/2017.
+// Copyright (c) 2017 Andrea Bizzotto (bizz84@gmail.com)
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+// THE SOFTWARE.
+
+import Foundation
+
+class InAppReceiptVerificator: NSObject {
+
+    let appStoreReceiptURL: URL?
+    init(appStoreReceiptURL: URL? = Bundle.main.appStoreReceiptURL) {
+        self.appStoreReceiptURL = appStoreReceiptURL
+    }
+
+    var appStoreReceiptData: Data? {
+        guard let receiptDataURL = appStoreReceiptURL,
+            let data = try? Data(contentsOf: receiptDataURL) else {
+            return nil
+        }
+        return data
+    }
+
+    private var receiptRefreshRequest: InAppReceiptRefreshRequest?
+
+    /**
+     *  Verify application receipt. This method does two things:
+     *  * If the receipt is missing, refresh it
+     *  * If the receipt is available or is refreshed, validate it
+     *  - Parameter validator: Validator to check the encrypted receipt and return the receipt in readable format
+     *  - Parameter password: Your app’s shared secret (a hexadecimal string). Only used for receipts that contain auto-renewable subscriptions.
+     *  - Parameter refresh: closure to perform receipt refresh (this is made explicit for testability)
+     *  - Parameter completion: handler for result
+     */
+    public func verifyReceipt(using validator: ReceiptValidator,
+                              password: String? = nil,
+                              refresh: InAppReceiptRefreshRequest.ReceiptRefresh = InAppReceiptRefreshRequest.refresh,
+                              completion: @escaping (VerifyReceiptResult) -> Void) {
+        
+        if let receiptData = appStoreReceiptData {
+            
+            verify(receiptData: receiptData, using: validator, password: password, completion: completion)
+        } else {
+            
+            receiptRefreshRequest = refresh(nil) { result in
+                
+                self.receiptRefreshRequest = nil
+                
+                switch result {
+                case .success:
+                    if let receiptData = self.appStoreReceiptData {
+                        self.verify(receiptData: receiptData, using: validator, password: password, completion: completion)
+                    } else {
+                        completion(.error(error: .noReceiptData))
+                    }
+                case .error(let e):
+                    completion(.error(error: .networkError(error: e)))
+                }
+            }
+        }
+    }
+    
+    /**
+     *  - Parameter receiptData: encrypted receipt data
+     *  - Parameter validator: Validator to check the encrypted receipt and return the receipt in readable format
+     *  - Parameter password: Your app’s shared secret (a hexadecimal string). Only used for receipts that contain auto-renewable subscriptions.
+     *  - Parameter completion: handler for result
+     */
+    private func verify(receiptData: Data, using validator: ReceiptValidator, password: String? = nil, completion: @escaping (VerifyReceiptResult) -> Void) {
+     
+        // The base64 encoded receipt data.
+        let base64EncodedString = receiptData.base64EncodedString(options: [])
+
+        validator.validate(receipt: base64EncodedString, password: password) { result in
+            
+            DispatchQueue.main.async {
+                completion(result)
+            }
+        }
+    }
+}

+ 1 - 1
SwiftyStoreKit/SwiftyStoreKit+Types.swift

@@ -138,7 +138,7 @@ public struct ReceiptItem {
 public enum ReceiptError: Swift.Error {
     // No receipt data
     case noReceiptData
-    // No data receice
+    // No data received
     case noRemoteData
     // Error when encoding HTTP body into JSON
     case requestBodyEncodeError(error: Swift.Error)

+ 38 - 52
SwiftyStoreKit/SwiftyStoreKit.swift

@@ -30,13 +30,15 @@ public class SwiftyStoreKit {
 
     private let paymentQueueController: PaymentQueueController
 
-    private var receiptRefreshRequest: InAppReceiptRefreshRequest?
+    fileprivate let receiptVerificator: InAppReceiptVerificator
 
     init(productsInfoController: ProductsInfoController = ProductsInfoController(),
-         paymentQueueController: PaymentQueueController = PaymentQueueController(paymentQueue: SKPaymentQueue.default())) {
+         paymentQueueController: PaymentQueueController = PaymentQueueController(paymentQueue: SKPaymentQueue.default()),
+         receiptVerificator: InAppReceiptVerificator = InAppReceiptVerificator()) {
 
         self.productsInfoController = productsInfoController
         self.paymentQueueController = paymentQueueController
+        self.receiptVerificator = receiptVerificator
     }
 
     // MARK: Internal methods
@@ -83,24 +85,6 @@ public class SwiftyStoreKit {
         paymentQueueController.finishTransaction(transaction)
     }
 
-    func refreshReceipt(_ receiptProperties: [String : Any]? = nil, completion: @escaping (RefreshReceiptResult) -> Void) {
-        receiptRefreshRequest = InAppReceiptRefreshRequest.refresh(receiptProperties) { result in
-
-            self.receiptRefreshRequest = nil
-
-            switch result {
-            case .success:
-                if let appStoreReceiptData = InAppReceipt.appStoreReceiptData {
-                    completion(.success(receiptData: appStoreReceiptData))
-                } else {
-                    completion(.error(error: ReceiptError.noReceiptData))
-                }
-            case .error(let e):
-                completion(.error(error: e))
-            }
-        }
-    }
-
     // MARK: private methods
     private func purchase(product: SKProduct, quantity: Int, atomically: Bool, applicationUsername: String = "", completion: @escaping (PurchaseResult) -> Void) {
         guard SwiftyStoreKit.canMakePayments else {
@@ -152,13 +136,19 @@ public class SwiftyStoreKit {
 extension SwiftyStoreKit {
 
     // MARK: Singleton
-    private static let sharedInstance = SwiftyStoreKit()
+    fileprivate static let sharedInstance = SwiftyStoreKit()
 
     // MARK: Public methods - Purchases
+    
     public class var canMakePayments: Bool {
         return SKPaymentQueue.canMakePayments()
     }
 
+    /**
+     *  Retrieve products information
+     *  - Parameter productIds: The set of product identifiers to retrieve corresponding products for
+     *  - Parameter completion: handler for result
+     */
     public class func retrieveProductsInfo(_ productIds: Set<String>, completion: @escaping (RetrieveResults) -> Void) {
 
         return sharedInstance.retrieveProductsInfo(productIds, completion: completion)
@@ -177,26 +167,36 @@ extension SwiftyStoreKit {
         sharedInstance.purchaseProduct(productId, quantity: quantity, atomically: atomically, applicationUsername: applicationUsername, completion: completion)
     }
 
+    /**
+     *  Restore purchases
+     *  - Parameter atomically: whether the product is purchased atomically (e.g. finishTransaction is called immediately)
+     *  - Parameter applicationUsername: an opaque identifier for the user’s account on your system
+     *  - Parameter completion: handler for result
+     */
     public class func restorePurchases(atomically: Bool = true, applicationUsername: String = "", completion: @escaping (RestoreResults) -> Void) {
 
         sharedInstance.restorePurchases(atomically: atomically, applicationUsername: applicationUsername, completion: completion)
     }
 
+    /**
+     *  Complete transactions
+     *  - Parameter atomically: whether the product is purchased atomically (e.g. finishTransaction is called immediately)
+     *  - Parameter completion: handler for result
+     */
     public class func completeTransactions(atomically: Bool = true, completion: @escaping ([Purchase]) -> Void) {
 
         sharedInstance.completeTransactions(atomically: atomically, completion: completion)
     }
 
+    /**
+     *  Finish a transaction
+     *  Once the content has been delivered, call this method to finish a transaction that was performed non-atomically
+     *  - Parameter transaction: transaction to finish
+     */
     public class func finishTransaction(_ transaction: PaymentTransaction) {
 
         sharedInstance.finishTransaction(transaction)
     }
-
-    // After verifying receive and have `ReceiptError.NoReceiptData`, refresh receipt using this method
-    public class func refreshReceipt(_ receiptProperties: [String : Any]? = nil, completion: @escaping (RefreshReceiptResult) -> Void) {
-
-        sharedInstance.refreshReceipt(receiptProperties, completion: completion)
-    }
 }
 
 extension SwiftyStoreKit {
@@ -207,55 +207,41 @@ extension SwiftyStoreKit {
      * Return receipt data from the application bundle. This is read from Bundle.main.appStoreReceiptURL
      */
     public static var localReceiptData: Data? {
-        return InAppReceipt.appStoreReceiptData
+        return sharedInstance.receiptVerificator.appStoreReceiptData
     }
 
     /**
      *  Verify application receipt
+     *  - Parameter validator: receipt validator to use
      *  - Parameter password: Only used for receipts that contain auto-renewable subscriptions. Your app’s shared secret (a hexadecimal string).
-     *  - Parameter session: the session used to make remote call.
      *  - Parameter completion: handler for result
      */
-    public class func verifyReceipt(
-        using validator: ReceiptValidator,
-        password: String? = nil,
-        completion:@escaping (VerifyReceiptResult) -> Void) {
-
-        InAppReceipt.verify(using: validator, password: password) { result in
+    public class func verifyReceipt(using validator: ReceiptValidator, password: String? = nil, completion: @escaping (VerifyReceiptResult) -> Void) {
 
-            DispatchQueue.main.async {
-                completion(result)
-            }
-        }
+        sharedInstance.receiptVerificator.verifyReceipt(using: validator, password: password, completion: completion)
     }
-
+    
     /**
      *  Verify the purchase of a Consumable or NonConsumable product in a receipt
      *  - Parameter productId: the product id of the purchase to verify
      *  - Parameter inReceipt: the receipt to use for looking up the purchase
      *  - return: either notPurchased or purchased
      */
-    public class func verifyPurchase(
-        productId: String,
-        inReceipt receipt: ReceiptInfo
-        ) -> VerifyPurchaseResult {
+    public class func verifyPurchase(productId: String, inReceipt receipt: ReceiptInfo) -> VerifyPurchaseResult {
+
         return InAppReceipt.verifyPurchase(productId: productId, inReceipt: receipt)
     }
 
     /**
      *  Verify the purchase of a subscription (auto-renewable, free or non-renewing) in a receipt. This method extracts all transactions mathing the given productId and sorts them by date in descending order, then compares the first transaction expiry date against the validUntil value.
+     *  - Parameter type: autoRenewable or nonRenewing
      *  - Parameter productId: the product id of the purchase to verify
      *  - Parameter inReceipt: the receipt to use for looking up the subscription
      *  - Parameter validUntil: date to check against the expiry date of the subscription. If nil, no verification
-     *  - Parameter validDuration: the duration of the subscription. Only required for non-renewable subscription.
-     *  - return: either NotPurchased or Purchased / Expired with the expiry date found in the receipt
+     *  - return: either .notPurchased or .purchased / .expired with the expiry date found in the receipt
      */
-    public class func verifySubscription(
-        type: SubscriptionType,
-        productId: String,
-        inReceipt receipt: ReceiptInfo,
-        validUntil date: Date = Date()
-        ) -> VerifySubscriptionResult {
+    public class func verifySubscription(type: SubscriptionType, productId: String, inReceipt receipt: ReceiptInfo, validUntil date: Date = Date()) -> VerifySubscriptionResult {
+
         return InAppReceipt.verifySubscription(type: type, productId: productId, inReceipt: receipt, validUntil: date)
     }
 }

+ 226 - 0
SwiftyStoreKitTests/InAppReceiptVerificatorTests.swift

@@ -0,0 +1,226 @@
+//
+//  InAppReceiptVerificatorTests.swift
+//  SwiftyStoreKit
+//
+//  Created by Andrea Bizzotto on 17/05/2017.
+// Copyright (c) 2017 Andrea Bizzotto (bizz84@gmail.com)
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+// THE SOFTWARE.
+
+import XCTest
+@testable import SwiftyStoreKit
+
+class TestReceiptValidator: ReceiptValidator {
+    var validateCalled = false
+    func validate(receipt: String, password autoRenewPassword: String?, completion: @escaping (VerifyReceiptResult) -> Void) {
+        validateCalled = true
+        completion(.success(receipt: [:]))
+    }
+}
+
+class TestInAppReceiptRefreshRequest: InAppReceiptRefreshRequest {
+    
+    override func start() {
+        // do nothing
+    }
+}
+
+extension VerifyReceiptResult: Equatable {
+    
+    static public func == (lhs: VerifyReceiptResult, rhs: VerifyReceiptResult) -> Bool {
+        switch (lhs, rhs) {
+        case (.success(_), .success(_)): return true
+        case (.error(let lhsError), .error(let rhsError)): return lhsError == rhsError
+        default: return false
+        }
+    }
+}
+
+extension ReceiptError: Equatable {
+    
+    static public func == (lhs: ReceiptError, rhs: ReceiptError) -> Bool {
+        switch (lhs, rhs) {
+        case (.noReceiptData, .noReceiptData): return true
+        case (.noRemoteData, .noRemoteData): return true
+        case (.requestBodyEncodeError(_), .requestBodyEncodeError(_)): return true
+        case (.networkError(_), .networkError(_)): return true
+        case (.jsonDecodeError(_), .jsonDecodeError(_)): return true
+        case (.receiptInvalid(_, _), .receiptInvalid(_, _)): return true
+        default: return false
+        }
+    }
+}
+
+class InAppReceiptVerificatorTests: XCTestCase {
+    
+    // MARK: refresh tests (no receipt url or no receipt data)
+    func testVerifyReceipt_when_appStoreReceiptURLIsNil_then_callsRefresh() {
+        
+        let validator = TestReceiptValidator()
+        let verificator = InAppReceiptVerificator(appStoreReceiptURL: nil)
+        
+        var refreshCalled = false
+        verificator.verifyReceipt(using: validator, password: nil, refresh: { (properties, callback) -> InAppReceiptRefreshRequest in
+            
+            refreshCalled = true
+            return TestInAppReceiptRefreshRequest(receiptProperties: properties, callback: callback)
+            
+        }) { _ in
+            
+        }
+        XCTAssertTrue(refreshCalled)
+    }
+
+    func testVerifyReceipt_when_appStoreReceiptURLIsNotNil_noReceiptData_then_callsRefresh() {
+        
+        let testReceiptURL = makeReceiptURL()
+        
+        let validator = TestReceiptValidator()
+        let verificator = InAppReceiptVerificator(appStoreReceiptURL: testReceiptURL)
+        
+        var refreshCalled = false
+        verificator.verifyReceipt(using: validator, password: nil, refresh: { (properties, callback) -> InAppReceiptRefreshRequest in
+            
+            refreshCalled = true
+            return TestInAppReceiptRefreshRequest(receiptProperties: properties, callback: callback)
+            
+        }) { _ in
+            
+        }
+        XCTAssertTrue(refreshCalled)
+    }
+
+    func testVerifyReceipt_when_appStoreReceiptURLIsNil_refreshCallbackError_then_errorNetworkError() {
+        
+        let validator = TestReceiptValidator()
+        let verificator = InAppReceiptVerificator(appStoreReceiptURL: nil)
+        let refreshError = NSError(domain: "", code: 0, userInfo: nil)
+        
+        verificator.verifyReceipt(using: validator, password: nil, refresh: { (properties, callback) -> InAppReceiptRefreshRequest in
+            
+            callback(.error(e: refreshError))
+            return TestInAppReceiptRefreshRequest(receiptProperties: properties, callback: callback)
+            
+        }) { result in
+            
+            XCTAssertEqual(result, VerifyReceiptResult.error(error: ReceiptError.networkError(error: refreshError)))
+        }
+    }
+
+    func testVerifyReceipt_when_appStoreReceiptURLIsNil_refreshCallbackSuccess_receiptDataNotWritten_then_errorNoReceiptData_validateNotCalled() {
+        
+        let validator = TestReceiptValidator()
+        let verificator = InAppReceiptVerificator(appStoreReceiptURL: nil)
+        
+        verificator.verifyReceipt(using: validator, password: nil, refresh: { (properties, callback) -> InAppReceiptRefreshRequest in
+            
+            callback(.success)
+            return TestInAppReceiptRefreshRequest(receiptProperties: properties, callback: callback)
+            
+        }) { result in
+
+            XCTAssertEqual(result, VerifyReceiptResult.error(error: ReceiptError.noReceiptData))
+        }
+        XCTAssertFalse(validator.validateCalled)
+    }
+
+    func testVerifyReceipt_when_appStoreReceiptURLIsNil_noReceiptData_refreshCallbackSuccess_receiptDataWritten_then_errorNoReceiptData_validateNotCalled() {
+        
+        let testReceiptURL = makeReceiptURL()
+        
+        let validator = TestReceiptValidator()
+        let verificator = InAppReceiptVerificator(appStoreReceiptURL: nil)
+        
+        verificator.verifyReceipt(using: validator, password: nil, refresh: { (properties, callback) -> InAppReceiptRefreshRequest in
+            
+            writeReceiptData(to: testReceiptURL)
+            callback(.success)
+            return TestInAppReceiptRefreshRequest(receiptProperties: properties, callback: callback)
+            
+        }) { result in
+            
+            XCTAssertEqual(result, VerifyReceiptResult.error(error: ReceiptError.noReceiptData))
+        }
+        XCTAssertFalse(validator.validateCalled)
+        removeReceiptData(at: testReceiptURL)
+    }
+    
+    func testVerifyReceipt_when_appStoreReceiptURLIsNotNil_noReceiptData_refreshCallbackSuccess_receiptDataWritten_then_validateIsCalled() {
+        
+        let testReceiptURL = makeReceiptURL()
+        
+        let validator = TestReceiptValidator()
+        let verificator = InAppReceiptVerificator(appStoreReceiptURL: testReceiptURL)
+        
+        verificator.verifyReceipt(using: validator, password: nil, refresh: { (properties, callback) -> InAppReceiptRefreshRequest in
+            
+            writeReceiptData(to: testReceiptURL)
+            callback(.success)
+            return TestInAppReceiptRefreshRequest(receiptProperties: properties, callback: callback)
+            
+        }) { _ in
+            
+        }
+        XCTAssertTrue(validator.validateCalled)
+        removeReceiptData(at: testReceiptURL)
+    }
+    
+    // MARK: non-refresh tests (receipt url and data are set)
+    func testVerifyReceipt_when_appStoreReceiptURLIsNotNil_hasReceiptData_then_refreshNotCalled_validateIsCalled() {
+        
+        let testReceiptURL = makeReceiptURL()
+        writeReceiptData(to: testReceiptURL)
+
+        let validator = TestReceiptValidator()
+        let verificator = InAppReceiptVerificator(appStoreReceiptURL: testReceiptURL)
+        
+        verificator.verifyReceipt(using: validator, password: nil, refresh: { (properties, callback) -> InAppReceiptRefreshRequest in
+            
+            XCTFail("refresh should not be called if we already have a receipt")
+            return TestInAppReceiptRefreshRequest(receiptProperties: properties, callback: callback)
+            
+        }) { _ in
+            
+        }
+        XCTAssertTrue(validator.validateCalled)
+        removeReceiptData(at: testReceiptURL)
+    }
+    
+    // MARK: Helpers
+    func makeReceiptURL() -> URL {
+        
+        guard let testFolderURL = try? FileManager.default.url(for: .documentDirectory, in: .allDomainsMask, appropriateFor: nil, create: false) else {
+            fatalError("Invalid test folder")
+        }
+        return testFolderURL.appendingPathComponent("receipt.data")
+    }
+    
+    func writeReceiptData(to url: URL) {
+        
+        guard let testReceiptData = NSData(base64Encoded: "encrypted-receipt", options: .ignoreUnknownCharacters) else {
+            fatalError("Invalid receipt data")
+        }
+        try? testReceiptData.write(to: url)
+    }
+    
+    func removeReceiptData(at url: URL) {
+        try? FileManager.default.removeItem(at: url)
+    }
+
+}