Selaa lähdekoodia

Merge develop into master

Andrea Bizzotto 8 vuotta sitten
vanhempi
commit
88537fc8b9

+ 22 - 47
README.md

@@ -308,33 +308,12 @@ The project includes demo apps [for iOS](https://github.com/bizz84/SwiftyStoreKi
 Note that the pre-registered in app purchases in the demo apps are for illustration purposes only and may not work as iTunes Connect may invalidate them.
 Note that the pre-registered in app purchases in the demo apps are for illustration purposes only and may not work as iTunes Connect may invalidate them.
 
 
 #### Features
 #### Features
+
 - Super easy to use block based API
 - Super easy to use block based API
 - Support for consumable, non-consumable in-app purchases
 - Support for consumable, non-consumable in-app purchases
 - Support for free, auto renewable and non renewing subscriptions
 - Support for free, auto renewable and non renewing subscriptions
 - Receipt verification
 - Receipt verification
 - iOS, tvOS and macOS compatible
 - iOS, tvOS and macOS compatible
-- enum-based error handling
-
-## Known issues
-
-#### Requests lifecycle
-
-While SwiftyStoreKit tries handle concurrent purchase or restore purchases requests, it is not guaranteed that this will always work flawlessly.
-This is in part because using a closure-based API does not map perfectly well with the lifecycle of payments in `SKPaymentQueue`.
-
-In real applications the following could happen:
-
-1. User starts a purchase
-2. User kills the app
-3. OS continues processing this, resulting in a failed or successful purchase
-4. App is restarted (payment queue is not updated yet)
-5. User starts another purchase (the old transaction may interfere with the new purchase)
-
-To prevent situations like this from happening, a `completeTransactions()` method has been added in version 0.2.8. This should be called when the app starts as it can take care of clearing the payment queue and notifying the app of the transactions that have finished.
-
-#### Multiple accounts
-
-The user can background the hosting application and change the Apple ID used with the App Store, then foreground the app. This has been observed to cause problems with SwiftyStoreKit - other IAP implementations may suffer from this as well.
 
 
 ## Essential Reading
 ## Essential Reading
 * [Apple - WWDC16, Session 702: Using Store Kit for In-app Purchases with Swift 3](https://developer.apple.com/videos/play/wwdc2016/702/)
 * [Apple - WWDC16, Session 702: Using Store Kit for In-app Purchases with Swift 3](https://developer.apple.com/videos/play/wwdc2016/702/)
@@ -346,45 +325,41 @@ The user can background the hosting application and change the Apple ID used wit
 * [objc.io - Receipt Validation](https://www.objc.io/issues/17-security/receipt-validation/)
 * [objc.io - Receipt Validation](https://www.objc.io/issues/17-security/receipt-validation/)
 
 
 
 
-## Implementation Details
+## Payment flows - implementation Details
 In order to make a purchase, two operations are needed:
 In order to make a purchase, two operations are needed:
 
 
-- Obtain the ```SKProduct``` corresponding to the productId that identifies the app purchase, via ```SKProductRequest```.
+- Perform a `SKProductRequest` to obtain the `SKProduct` corresponding to the product identifier.
 
 
-- Submit the payment for that product via ```SKPaymentQueue```.
+- Submit the payment and listen for updated transactions on the `SKPaymentQueue`.
 
 
 The framework takes care of caching SKProducts so that future requests for the same ```SKProduct``` don't need to perform a new ```SKProductRequest```.
 The framework takes care of caching SKProducts so that future requests for the same ```SKProduct``` don't need to perform a new ```SKProductRequest```.
 
 
-### Requesting products information
+### Payment queue
 
 
-SwiftyStoreKit wraps the delegate-based ```SKProductRequest``` API with a block based class named ```InAppProductQueryRequest```, which returns a `RetrieveResults` value with information about the obtained products:
+The following list outlines how requests are processed by SwiftyStoreKit.
 
 
-```swift
-public struct RetrieveResults {
-    public let retrievedProducts: Set<SKProduct>
-    public let invalidProductIDs: Set<String>
-    public let error: NSError?
-}
-```
-This value is then surfaced back to the caller of the `retrieveProductsInfo()` method the completion closure so that the client can update accordingly.
+* `SKPaymentQueue` is used to queue payments or restore purchases requests.
+* Payments are processed serially and in-order and require user interaction.
+* Restore purchases requests don't require user interaction and can jump ahead of the queue.
+* `SKPaymentQueue` rejects multiple restore purchases calls.
+* Failed translations only ever belong to queued payment request.
+* `restoreCompletedTransactionsFailedWithError` is always called when a restore purchases request fails.
+* `paymentQueueRestoreCompletedTransactionsFinished` is always called following 0 or more update transactions when a restore purchases request succeeds.
+* A complete transactions handler is require to catch any transactions that are updated when the app is not running.
+* Registering a complete transactions handler when the app launches ensures that any pending transactions can be cleared.
+* If a complete transactions handler is missing, pending transactions can be mis-attributed to any new incoming payments or restore purchases.
 
 
-### Purchasing a product / Restoring purchases
-`InAppProductPurchaseRequest` is a wrapper class for `SKPaymentQueue` that can be used to purchase a product or restore purchases.
+The order in which transaction updates are processed is:
 
 
-The class conforms to the `SKPaymentTransactionObserver` protocol in order to receive transactions notifications from the payment queue. The following outcomes are defined for a purchase/restore action:
+1. payments (transactionState: `.purchased` and `.failed` for matching product identifiers)
+2. restore purchases (transactionState: `.restored`, or `restoreCompletedTransactionsFailedWithError`, or `paymentQueueRestoreCompletedTransactionsFinished`)
+3. complete transactions (transactionState: `.purchased`, `.failed`, `.restored`, `.deferred`)
 
 
-```swift
-enum TransactionResult {
-    case purchased(productId: String)
-    case restored(productId: String)
-    case failed(error: NSError)
-}
-```
-Depending on the operation, the completion closure for `InAppProductPurchaseRequest` is then mapped to either a `PurchaseResult` or a `RestoreResults` value and returned to the caller.
+Any transactions where state == `.purchasing` are ignored.
 
 
 ## Contributing
 ## Contributing
 
 
-[Read here](CONTRIBUTING.md)
+[Read here](CONTRIBUTING.md).
 
 
 ## Credits
 ## Credits
 Many thanks to [phimage](https://github.com/phimage) for adding macOS support and receipt verification.
 Many thanks to [phimage](https://github.com/phimage) for adding macOS support and receipt verification.

+ 0 - 17
SwiftyStoreKit-iOS-Demo/AppDelegate.swift

@@ -32,28 +32,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
 
 
     func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey : Any]? = nil) -> Bool {
     func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey : Any]? = nil) -> Bool {
 
 
-        verifyReceipt()
-
         completeIAPTransactions()
         completeIAPTransactions()
 
 
         return true
         return true
     }
     }
     
     
-    func verifyReceipt() {
-
-		let appleValidator = AppleReceiptValidator(service: .production)
-		SwiftyStoreKit.verifyReceipt(using: appleValidator, password: "your-shared-secret") { result in
-            switch result {
-            case .success(let receipt):
-                print("\(receipt)")
-            case .error(let error):
-                if case .noReceiptData = error {
-                    SwiftyStoreKit.refreshReceipt { result in }
-                }
-            }
-        }
-    }
-    
     func completeIAPTransactions() {
     func completeIAPTransactions() {
         
         
         SwiftyStoreKit.completeTransactions(atomically: true) { products in
         SwiftyStoreKit.completeTransactions(atomically: true) { products in

+ 203 - 21
SwiftyStoreKit.xcodeproj/project.pbxproj

@@ -10,24 +10,39 @@
 		1592CD501E27756500D321E6 /* AppleReceiptValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1592CD4F1E27756500D321E6 /* AppleReceiptValidator.swift */; };
 		1592CD501E27756500D321E6 /* AppleReceiptValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1592CD4F1E27756500D321E6 /* AppleReceiptValidator.swift */; };
 		1592CD511E27756500D321E6 /* AppleReceiptValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1592CD4F1E27756500D321E6 /* AppleReceiptValidator.swift */; };
 		1592CD511E27756500D321E6 /* AppleReceiptValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1592CD4F1E27756500D321E6 /* AppleReceiptValidator.swift */; };
 		1592CD521E27756500D321E6 /* AppleReceiptValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1592CD4F1E27756500D321E6 /* AppleReceiptValidator.swift */; };
 		1592CD521E27756500D321E6 /* AppleReceiptValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1592CD4F1E27756500D321E6 /* AppleReceiptValidator.swift */; };
-		54B069911CF742CE00BAFE38 /* InAppCompleteTransactionsObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 651A71241CD651AF000B4091 /* InAppCompleteTransactionsObserver.swift */; };
 		54B069921CF742D100BAFE38 /* InAppReceipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A7C7621C29B8D00053ED64 /* InAppReceipt.swift */; };
 		54B069921CF742D100BAFE38 /* InAppReceipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A7C7621C29B8D00053ED64 /* InAppReceipt.swift */; };
 		54B069931CF742D300BAFE38 /* InAppReceiptRefreshRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4083C561C2AB0A900295248 /* InAppReceiptRefreshRequest.swift */; };
 		54B069931CF742D300BAFE38 /* InAppReceiptRefreshRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4083C561C2AB0A900295248 /* InAppReceiptRefreshRequest.swift */; };
 		54B069941CF742D600BAFE38 /* InAppProductQueryRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6502F6231B98586A004E342D /* InAppProductQueryRequest.swift */; };
 		54B069941CF742D600BAFE38 /* InAppProductQueryRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6502F6231B98586A004E342D /* InAppProductQueryRequest.swift */; };
-		54B069951CF742D900BAFE38 /* InAppProductPurchaseRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6502F6221B98586A004E342D /* InAppProductPurchaseRequest.swift */; };
 		54B069961CF744DC00BAFE38 /* OS.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40C680F1C29414C00B60B7E /* OS.swift */; };
 		54B069961CF744DC00BAFE38 /* OS.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40C680F1C29414C00B60B7E /* OS.swift */; };
 		54C0D5681CF7428400F90BCE /* SwiftyStoreKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6502F6241B98586A004E342D /* SwiftyStoreKit.swift */; };
 		54C0D5681CF7428400F90BCE /* SwiftyStoreKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6502F6241B98586A004E342D /* SwiftyStoreKit.swift */; };
-		6502F63A1B985C9E004E342D /* InAppProductPurchaseRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6502F6221B98586A004E342D /* InAppProductPurchaseRequest.swift */; };
 		6502F63B1B985CA1004E342D /* InAppProductQueryRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6502F6231B98586A004E342D /* InAppProductQueryRequest.swift */; };
 		6502F63B1B985CA1004E342D /* InAppProductQueryRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6502F6231B98586A004E342D /* InAppProductQueryRequest.swift */; };
 		6502F63C1B985CA4004E342D /* SwiftyStoreKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6502F6241B98586A004E342D /* SwiftyStoreKit.swift */; };
 		6502F63C1B985CA4004E342D /* SwiftyStoreKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6502F6241B98586A004E342D /* SwiftyStoreKit.swift */; };
-		651A71251CD651AF000B4091 /* InAppCompleteTransactionsObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 651A71241CD651AF000B4091 /* InAppCompleteTransactionsObserver.swift */; };
-		651A71261CD651AF000B4091 /* InAppCompleteTransactionsObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 651A71241CD651AF000B4091 /* InAppCompleteTransactionsObserver.swift */; };
+		650307F21E3163AA001332A4 /* RestorePurchasesControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 650307F11E3163AA001332A4 /* RestorePurchasesControllerTests.swift */; };
+		650307F41E3177EF001332A4 /* RestorePurchasesController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 650307F31E3177EF001332A4 /* RestorePurchasesController.swift */; };
+		650307F51E3177EF001332A4 /* RestorePurchasesController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 650307F31E3177EF001332A4 /* RestorePurchasesController.swift */; };
+		650307F61E3177EF001332A4 /* RestorePurchasesController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 650307F31E3177EF001332A4 /* RestorePurchasesController.swift */; };
+		650307F81E317BCF001332A4 /* CompleteTransactionsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 650307F71E317BCF001332A4 /* CompleteTransactionsController.swift */; };
+		650307F91E317BCF001332A4 /* CompleteTransactionsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 650307F71E317BCF001332A4 /* CompleteTransactionsController.swift */; };
+		650307FA1E317BCF001332A4 /* CompleteTransactionsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 650307F71E317BCF001332A4 /* CompleteTransactionsController.swift */; };
+		650307FC1E33154F001332A4 /* ProductsInfoController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 650307FB1E33154F001332A4 /* ProductsInfoController.swift */; };
+		650307FD1E33154F001332A4 /* ProductsInfoController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 650307FB1E33154F001332A4 /* ProductsInfoController.swift */; };
+		650307FE1E33154F001332A4 /* ProductsInfoController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 650307FB1E33154F001332A4 /* ProductsInfoController.swift */; };
 		653722811DB8282600C8F944 /* SKProduct+LocalizedPrice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 653722801DB8282600C8F944 /* SKProduct+LocalizedPrice.swift */; };
 		653722811DB8282600C8F944 /* SKProduct+LocalizedPrice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 653722801DB8282600C8F944 /* SKProduct+LocalizedPrice.swift */; };
 		653722821DB8290A00C8F944 /* SKProduct+LocalizedPrice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 653722801DB8282600C8F944 /* SKProduct+LocalizedPrice.swift */; };
 		653722821DB8290A00C8F944 /* SKProduct+LocalizedPrice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 653722801DB8282600C8F944 /* SKProduct+LocalizedPrice.swift */; };
 		653722831DB8290B00C8F944 /* SKProduct+LocalizedPrice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 653722801DB8282600C8F944 /* SKProduct+LocalizedPrice.swift */; };
 		653722831DB8290B00C8F944 /* SKProduct+LocalizedPrice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 653722801DB8282600C8F944 /* SKProduct+LocalizedPrice.swift */; };
+		658A08371E2EC24E0074A98F /* PaymentQueueController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 658A08361E2EC24E0074A98F /* PaymentQueueController.swift */; };
+		658A08381E2EC24E0074A98F /* PaymentQueueController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 658A08361E2EC24E0074A98F /* PaymentQueueController.swift */; };
+		658A08391E2EC24E0074A98F /* PaymentQueueController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 658A08361E2EC24E0074A98F /* PaymentQueueController.swift */; };
+		658A08431E2EC5120074A98F /* SwiftyStoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6502F62D1B985C40004E342D /* SwiftyStoreKit.framework */; };
+		658A084A1E2EC5350074A98F /* PaymentQueueControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 658A08491E2EC5350074A98F /* PaymentQueueControllerTests.swift */; };
+		658A084C1E2EC5960074A98F /* PaymentQueueSpy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 658A084B1E2EC5960074A98F /* PaymentQueueSpy.swift */; };
 		65BB6CE81DDB018900218A0B /* SwiftyStoreKit+Types.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65BB6CE71DDB018900218A0B /* SwiftyStoreKit+Types.swift */; };
 		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 */; };
 		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 */; };
 		65BB6CEA1DDB018900218A0B /* SwiftyStoreKit+Types.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65BB6CE71DDB018900218A0B /* SwiftyStoreKit+Types.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 */; };
+		65F70ACB1E2EDC3700BF040D /* PaymentsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F70AC81E2EDC3700BF040D /* PaymentsController.swift */; };
 		65F7DF711DCD4DF000835D30 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F7DF681DCD4DF000835D30 /* AppDelegate.swift */; };
 		65F7DF711DCD4DF000835D30 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F7DF681DCD4DF000835D30 /* AppDelegate.swift */; };
 		65F7DF721DCD4DF000835D30 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 65F7DF691DCD4DF000835D30 /* Assets.xcassets */; };
 		65F7DF721DCD4DF000835D30 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 65F7DF691DCD4DF000835D30 /* Assets.xcassets */; };
 		65F7DF731DCD4DF000835D30 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 65F7DF6A1DCD4DF000835D30 /* LaunchScreen.storyboard */; };
 		65F7DF731DCD4DF000835D30 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 65F7DF6A1DCD4DF000835D30 /* LaunchScreen.storyboard */; };
@@ -43,12 +58,15 @@
 		65F7DF9A1DCD536700835D30 /* SwiftyStoreKit-iOS.h in Headers */ = {isa = PBXBuildFile; fileRef = 65F7DF971DCD536100835D30 /* SwiftyStoreKit-iOS.h */; settings = {ATTRIBUTES = (Public, ); }; };
 		65F7DF9A1DCD536700835D30 /* SwiftyStoreKit-iOS.h in Headers */ = {isa = PBXBuildFile; fileRef = 65F7DF971DCD536100835D30 /* SwiftyStoreKit-iOS.h */; settings = {ATTRIBUTES = (Public, ); }; };
 		65F7DF9B1DCD537800835D30 /* SwiftyStoreKit-macOS.h in Headers */ = {isa = PBXBuildFile; fileRef = 65F7DF981DCD536100835D30 /* SwiftyStoreKit-macOS.h */; settings = {ATTRIBUTES = (Public, ); }; };
 		65F7DF9B1DCD537800835D30 /* SwiftyStoreKit-macOS.h in Headers */ = {isa = PBXBuildFile; fileRef = 65F7DF981DCD536100835D30 /* SwiftyStoreKit-macOS.h */; settings = {ATTRIBUTES = (Public, ); }; };
 		65F7DF9C1DCD537F00835D30 /* SwiftyStoreKit-tvOS.h in Headers */ = {isa = PBXBuildFile; fileRef = 65F7DF991DCD536100835D30 /* SwiftyStoreKit-tvOS.h */; settings = {ATTRIBUTES = (Public, ); }; };
 		65F7DF9C1DCD537F00835D30 /* SwiftyStoreKit-tvOS.h in Headers */ = {isa = PBXBuildFile; fileRef = 65F7DF991DCD536100835D30 /* SwiftyStoreKit-tvOS.h */; settings = {ATTRIBUTES = (Public, ); }; };
+		C3099C071E2FCDAA00392A54 /* PaymentsControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3099C061E2FCDAA00392A54 /* PaymentsControllerTests.swift */; };
+		C3099C091E2FCE3A00392A54 /* TestProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3099C081E2FCE3A00392A54 /* TestProduct.swift */; };
+		C3099C0B1E2FD13200392A54 /* TestPaymentTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3099C0A1E2FD13200392A54 /* TestPaymentTransaction.swift */; };
+		C3099C191E3206C700392A54 /* CompleteTransactionsControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3099C181E3206C700392A54 /* CompleteTransactionsControllerTests.swift */; };
 		C4083C551C2AADB500295248 /* InAppReceipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A7C7621C29B8D00053ED64 /* InAppReceipt.swift */; };
 		C4083C551C2AADB500295248 /* InAppReceipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A7C7621C29B8D00053ED64 /* InAppReceipt.swift */; };
 		C4083C571C2AB0A900295248 /* InAppReceiptRefreshRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4083C561C2AB0A900295248 /* InAppReceiptRefreshRequest.swift */; };
 		C4083C571C2AB0A900295248 /* InAppReceiptRefreshRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4083C561C2AB0A900295248 /* InAppReceiptRefreshRequest.swift */; };
 		C40C68101C29414C00B60B7E /* OS.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40C680F1C29414C00B60B7E /* OS.swift */; };
 		C40C68101C29414C00B60B7E /* OS.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40C680F1C29414C00B60B7E /* OS.swift */; };
 		C40C68111C29419500B60B7E /* OS.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40C680F1C29414C00B60B7E /* OS.swift */; };
 		C40C68111C29419500B60B7E /* OS.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40C680F1C29414C00B60B7E /* OS.swift */; };
 		C4A7C7631C29B8D00053ED64 /* InAppReceipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A7C7621C29B8D00053ED64 /* InAppReceipt.swift */; };
 		C4A7C7631C29B8D00053ED64 /* InAppReceipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A7C7621C29B8D00053ED64 /* InAppReceipt.swift */; };
-		C4D74BC31C24CEDC0071AD3E /* InAppProductPurchaseRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6502F6221B98586A004E342D /* InAppProductPurchaseRequest.swift */; };
 		C4D74BC41C24CEDC0071AD3E /* InAppProductQueryRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6502F6231B98586A004E342D /* InAppProductQueryRequest.swift */; };
 		C4D74BC41C24CEDC0071AD3E /* InAppProductQueryRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6502F6231B98586A004E342D /* InAppProductQueryRequest.swift */; };
 		C4D74BC51C24CEDC0071AD3E /* SwiftyStoreKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6502F6241B98586A004E342D /* SwiftyStoreKit.swift */; };
 		C4D74BC51C24CEDC0071AD3E /* SwiftyStoreKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6502F6241B98586A004E342D /* SwiftyStoreKit.swift */; };
 		C4F69A8A1C2E0D21009DD8BD /* InAppReceiptRefreshRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4083C561C2AB0A900295248 /* InAppReceiptRefreshRequest.swift */; };
 		C4F69A8A1C2E0D21009DD8BD /* InAppReceiptRefreshRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4083C561C2AB0A900295248 /* InAppReceiptRefreshRequest.swift */; };
@@ -57,6 +75,20 @@
 /* End PBXBuildFile section */
 /* End PBXBuildFile section */
 
 
 /* Begin PBXContainerItemProxy section */
 /* Begin PBXContainerItemProxy section */
+		658A08441E2EC5120074A98F /* PBXContainerItemProxy */ = {
+			isa = PBXContainerItemProxy;
+			containerPortal = 6502F5F61B985833004E342D /* Project object */;
+			proxyType = 1;
+			remoteGlobalIDString = 6502F62C1B985C40004E342D;
+			remoteInfo = SwiftyStoreKit_iOS;
+		};
+		658A084D1E2EC83F0074A98F /* PBXContainerItemProxy */ = {
+			isa = PBXContainerItemProxy;
+			containerPortal = 6502F5F61B985833004E342D /* Project object */;
+			proxyType = 1;
+			remoteGlobalIDString = 6502F5FD1B985833004E342D;
+			remoteInfo = SwiftyStoreKit_iOSDemo;
+		};
 		65F7DF901DCD524300835D30 /* PBXContainerItemProxy */ = {
 		65F7DF901DCD524300835D30 /* PBXContainerItemProxy */ = {
 			isa = PBXContainerItemProxy;
 			isa = PBXContainerItemProxy;
 			containerPortal = 6502F5F61B985833004E342D /* Project object */;
 			containerPortal = 6502F5F61B985833004E342D /* Project object */;
@@ -102,13 +134,22 @@
 		1592CD4F1E27756500D321E6 /* AppleReceiptValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppleReceiptValidator.swift; sourceTree = "<group>"; };
 		1592CD4F1E27756500D321E6 /* AppleReceiptValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppleReceiptValidator.swift; sourceTree = "<group>"; };
 		54C0D52C1CF7404500F90BCE /* SwiftyStoreKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SwiftyStoreKit.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		54C0D52C1CF7404500F90BCE /* SwiftyStoreKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SwiftyStoreKit.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		6502F5FE1B985833004E342D /* SwiftyStoreKit_iOSDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SwiftyStoreKit_iOSDemo.app; sourceTree = BUILT_PRODUCTS_DIR; };
 		6502F5FE1B985833004E342D /* SwiftyStoreKit_iOSDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SwiftyStoreKit_iOSDemo.app; sourceTree = BUILT_PRODUCTS_DIR; };
-		6502F6221B98586A004E342D /* InAppProductPurchaseRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppProductPurchaseRequest.swift; sourceTree = "<group>"; };
 		6502F6231B98586A004E342D /* InAppProductQueryRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppProductQueryRequest.swift; sourceTree = "<group>"; };
 		6502F6231B98586A004E342D /* InAppProductQueryRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppProductQueryRequest.swift; sourceTree = "<group>"; };
 		6502F6241B98586A004E342D /* SwiftyStoreKit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwiftyStoreKit.swift; sourceTree = "<group>"; };
 		6502F6241B98586A004E342D /* SwiftyStoreKit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwiftyStoreKit.swift; sourceTree = "<group>"; };
 		6502F62D1B985C40004E342D /* SwiftyStoreKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SwiftyStoreKit.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		6502F62D1B985C40004E342D /* SwiftyStoreKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SwiftyStoreKit.framework; sourceTree = BUILT_PRODUCTS_DIR; };
-		651A71241CD651AF000B4091 /* InAppCompleteTransactionsObserver.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppCompleteTransactionsObserver.swift; sourceTree = "<group>"; };
+		650307F11E3163AA001332A4 /* RestorePurchasesControllerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RestorePurchasesControllerTests.swift; sourceTree = "<group>"; };
+		650307F31E3177EF001332A4 /* RestorePurchasesController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RestorePurchasesController.swift; sourceTree = "<group>"; };
+		650307F71E317BCF001332A4 /* CompleteTransactionsController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CompleteTransactionsController.swift; sourceTree = "<group>"; };
+		650307FB1E33154F001332A4 /* ProductsInfoController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProductsInfoController.swift; sourceTree = "<group>"; };
 		653722801DB8282600C8F944 /* SKProduct+LocalizedPrice.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SKProduct+LocalizedPrice.swift"; sourceTree = "<group>"; };
 		653722801DB8282600C8F944 /* SKProduct+LocalizedPrice.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SKProduct+LocalizedPrice.swift"; sourceTree = "<group>"; };
+		658A08361E2EC24E0074A98F /* PaymentQueueController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaymentQueueController.swift; sourceTree = "<group>"; };
+		658A083E1E2EC5120074A98F /* SwiftyStoreKitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SwiftyStoreKitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+		658A08421E2EC5120074A98F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
+		658A08491E2EC5350074A98F /* PaymentQueueControllerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaymentQueueControllerTests.swift; sourceTree = "<group>"; };
+		658A084B1E2EC5960074A98F /* PaymentQueueSpy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaymentQueueSpy.swift; sourceTree = "<group>"; };
 		65BB6CE71DDB018900218A0B /* SwiftyStoreKit+Types.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SwiftyStoreKit+Types.swift"; sourceTree = "<group>"; };
 		65BB6CE71DDB018900218A0B /* SwiftyStoreKit+Types.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SwiftyStoreKit+Types.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>"; };
 		65F7DF681DCD4DF000835D30 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
 		65F7DF691DCD4DF000835D30 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
 		65F7DF691DCD4DF000835D30 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
 		65F7DF6B1DCD4DF000835D30 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
 		65F7DF6B1DCD4DF000835D30 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
@@ -127,6 +168,10 @@
 		65F7DF971DCD536100835D30 /* SwiftyStoreKit-iOS.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SwiftyStoreKit-iOS.h"; sourceTree = "<group>"; };
 		65F7DF971DCD536100835D30 /* SwiftyStoreKit-iOS.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SwiftyStoreKit-iOS.h"; sourceTree = "<group>"; };
 		65F7DF981DCD536100835D30 /* SwiftyStoreKit-macOS.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SwiftyStoreKit-macOS.h"; sourceTree = "<group>"; };
 		65F7DF981DCD536100835D30 /* SwiftyStoreKit-macOS.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SwiftyStoreKit-macOS.h"; sourceTree = "<group>"; };
 		65F7DF991DCD536100835D30 /* SwiftyStoreKit-tvOS.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SwiftyStoreKit-tvOS.h"; sourceTree = "<group>"; };
 		65F7DF991DCD536100835D30 /* SwiftyStoreKit-tvOS.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SwiftyStoreKit-tvOS.h"; sourceTree = "<group>"; };
+		C3099C061E2FCDAA00392A54 /* PaymentsControllerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaymentsControllerTests.swift; sourceTree = "<group>"; };
+		C3099C081E2FCE3A00392A54 /* TestProduct.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestProduct.swift; sourceTree = "<group>"; };
+		C3099C0A1E2FD13200392A54 /* TestPaymentTransaction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestPaymentTransaction.swift; sourceTree = "<group>"; };
+		C3099C181E3206C700392A54 /* CompleteTransactionsControllerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CompleteTransactionsControllerTests.swift; sourceTree = "<group>"; };
 		C4083C561C2AB0A900295248 /* InAppReceiptRefreshRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppReceiptRefreshRequest.swift; sourceTree = "<group>"; };
 		C4083C561C2AB0A900295248 /* InAppReceiptRefreshRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppReceiptRefreshRequest.swift; sourceTree = "<group>"; };
 		C40C680F1C29414C00B60B7E /* OS.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OS.swift; sourceTree = "<group>"; };
 		C40C680F1C29414C00B60B7E /* OS.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OS.swift; sourceTree = "<group>"; };
 		C4A7C7621C29B8D00053ED64 /* InAppReceipt.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppReceipt.swift; sourceTree = "<group>"; };
 		C4A7C7621C29B8D00053ED64 /* InAppReceipt.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppReceipt.swift; sourceTree = "<group>"; };
@@ -157,6 +202,14 @@
 			);
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 			runOnlyForDeploymentPostprocessing = 0;
 		};
 		};
+		658A083B1E2EC5120074A98F /* Frameworks */ = {
+			isa = PBXFrameworksBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				658A08431E2EC5120074A98F /* SwiftyStoreKit.framework in Frameworks */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
 		C4D74BB71C24CEC90071AD3E /* Frameworks */ = {
 		C4D74BB71C24CEC90071AD3E /* Frameworks */ = {
 			isa = PBXFrameworksBuildPhase;
 			isa = PBXFrameworksBuildPhase;
 			buildActionMask = 2147483647;
 			buildActionMask = 2147483647;
@@ -181,6 +234,7 @@
 				6502F6001B985833004E342D /* SwiftyStoreKit */,
 				6502F6001B985833004E342D /* SwiftyStoreKit */,
 				65F7DF671DCD4DF000835D30 /* SwiftyStoreKit-iOS-Demo */,
 				65F7DF671DCD4DF000835D30 /* SwiftyStoreKit-iOS-Demo */,
 				65F7DF7D1DCD4FC500835D30 /* SwiftyStoreKit-macOS-Demo */,
 				65F7DF7D1DCD4FC500835D30 /* SwiftyStoreKit-macOS-Demo */,
+				658A083F1E2EC5120074A98F /* SwiftyStoreKitTests */,
 				6502F5FF1B985833004E342D /* Products */,
 				6502F5FF1B985833004E342D /* Products */,
 			);
 			);
 			sourceTree = "<group>";
 			sourceTree = "<group>";
@@ -193,6 +247,7 @@
 				C4D74BBB1C24CEC90071AD3E /* SwiftyStoreKit.framework */,
 				C4D74BBB1C24CEC90071AD3E /* SwiftyStoreKit.framework */,
 				C4FD3A011C2954C10035CFF3 /* SwiftyStoreKit_macOSDemo.app */,
 				C4FD3A011C2954C10035CFF3 /* SwiftyStoreKit_macOSDemo.app */,
 				54C0D52C1CF7404500F90BCE /* SwiftyStoreKit.framework */,
 				54C0D52C1CF7404500F90BCE /* SwiftyStoreKit.framework */,
+				658A083E1E2EC5120074A98F /* SwiftyStoreKitTests.xctest */,
 			);
 			);
 			name = Products;
 			name = Products;
 			sourceTree = "<group>";
 			sourceTree = "<group>";
@@ -200,21 +255,40 @@
 		6502F6001B985833004E342D /* SwiftyStoreKit */ = {
 		6502F6001B985833004E342D /* SwiftyStoreKit */ = {
 			isa = PBXGroup;
 			isa = PBXGroup;
 			children = (
 			children = (
-				6502F6221B98586A004E342D /* InAppProductPurchaseRequest.swift */,
+				6502F6241B98586A004E342D /* SwiftyStoreKit.swift */,
+				65BB6CE71DDB018900218A0B /* SwiftyStoreKit+Types.swift */,
+				650307FB1E33154F001332A4 /* ProductsInfoController.swift */,
 				6502F6231B98586A004E342D /* InAppProductQueryRequest.swift */,
 				6502F6231B98586A004E342D /* InAppProductQueryRequest.swift */,
+				658A08361E2EC24E0074A98F /* PaymentQueueController.swift */,
+				65F70AC81E2EDC3700BF040D /* PaymentsController.swift */,
+				650307F31E3177EF001332A4 /* RestorePurchasesController.swift */,
+				650307F71E317BCF001332A4 /* CompleteTransactionsController.swift */,
 				C4083C561C2AB0A900295248 /* InAppReceiptRefreshRequest.swift */,
 				C4083C561C2AB0A900295248 /* InAppReceiptRefreshRequest.swift */,
 				C4A7C7621C29B8D00053ED64 /* InAppReceipt.swift */,
 				C4A7C7621C29B8D00053ED64 /* InAppReceipt.swift */,
 				1592CD4F1E27756500D321E6 /* AppleReceiptValidator.swift */,
 				1592CD4F1E27756500D321E6 /* AppleReceiptValidator.swift */,
-				651A71241CD651AF000B4091 /* InAppCompleteTransactionsObserver.swift */,
 				653722801DB8282600C8F944 /* SKProduct+LocalizedPrice.swift */,
 				653722801DB8282600C8F944 /* SKProduct+LocalizedPrice.swift */,
-				6502F6241B98586A004E342D /* SwiftyStoreKit.swift */,
-				65BB6CE71DDB018900218A0B /* SwiftyStoreKit+Types.swift */,
 				C40C680F1C29414C00B60B7E /* OS.swift */,
 				C40C680F1C29414C00B60B7E /* OS.swift */,
 				65F7DF931DCD536100835D30 /* Platforms */,
 				65F7DF931DCD536100835D30 /* Platforms */,
 			);
 			);
 			path = SwiftyStoreKit;
 			path = SwiftyStoreKit;
 			sourceTree = "<group>";
 			sourceTree = "<group>";
 		};
 		};
+		658A083F1E2EC5120074A98F /* SwiftyStoreKitTests */ = {
+			isa = PBXGroup;
+			children = (
+				658A08421E2EC5120074A98F /* Info.plist */,
+				658A08491E2EC5350074A98F /* PaymentQueueControllerTests.swift */,
+				C3099C061E2FCDAA00392A54 /* PaymentsControllerTests.swift */,
+				650307F11E3163AA001332A4 /* RestorePurchasesControllerTests.swift */,
+				C3099C181E3206C700392A54 /* CompleteTransactionsControllerTests.swift */,
+				658A084B1E2EC5960074A98F /* PaymentQueueSpy.swift */,
+				65F70AC61E2ECBB300BF040D /* PaymentTransactionObserverFake.swift */,
+				C3099C081E2FCE3A00392A54 /* TestProduct.swift */,
+				C3099C0A1E2FD13200392A54 /* TestPaymentTransaction.swift */,
+			);
+			path = SwiftyStoreKitTests;
+			sourceTree = "<group>";
+		};
 		65F7DF671DCD4DF000835D30 /* SwiftyStoreKit-iOS-Demo */ = {
 		65F7DF671DCD4DF000835D30 /* SwiftyStoreKit-iOS-Demo */ = {
 			isa = PBXGroup;
 			isa = PBXGroup;
 			children = (
 			children = (
@@ -339,6 +413,25 @@
 			productReference = 6502F62D1B985C40004E342D /* SwiftyStoreKit.framework */;
 			productReference = 6502F62D1B985C40004E342D /* SwiftyStoreKit.framework */;
 			productType = "com.apple.product-type.framework";
 			productType = "com.apple.product-type.framework";
 		};
 		};
+		658A083D1E2EC5120074A98F /* SwiftyStoreKitTests */ = {
+			isa = PBXNativeTarget;
+			buildConfigurationList = 658A08461E2EC5120074A98F /* Build configuration list for PBXNativeTarget "SwiftyStoreKitTests" */;
+			buildPhases = (
+				658A083A1E2EC5120074A98F /* Sources */,
+				658A083B1E2EC5120074A98F /* Frameworks */,
+				658A083C1E2EC5120074A98F /* Resources */,
+			);
+			buildRules = (
+			);
+			dependencies = (
+				658A08451E2EC5120074A98F /* PBXTargetDependency */,
+				658A084E1E2EC83F0074A98F /* PBXTargetDependency */,
+			);
+			name = SwiftyStoreKitTests;
+			productName = SwiftyStoreKitTests;
+			productReference = 658A083E1E2EC5120074A98F /* SwiftyStoreKitTests.xctest */;
+			productType = "com.apple.product-type.bundle.unit-test";
+		};
 		C4D74BBA1C24CEC90071AD3E /* SwiftyStoreKit_macOS */ = {
 		C4D74BBA1C24CEC90071AD3E /* SwiftyStoreKit_macOS */ = {
 			isa = PBXNativeTarget;
 			isa = PBXNativeTarget;
 			buildConfigurationList = C4D74BC21C24CECA0071AD3E /* Build configuration list for PBXNativeTarget "SwiftyStoreKit_macOS" */;
 			buildConfigurationList = C4D74BC21C24CECA0071AD3E /* Build configuration list for PBXNativeTarget "SwiftyStoreKit_macOS" */;
@@ -382,7 +475,7 @@
 		6502F5F61B985833004E342D /* Project object */ = {
 		6502F5F61B985833004E342D /* Project object */ = {
 			isa = PBXProject;
 			isa = PBXProject;
 			attributes = {
 			attributes = {
-				LastSwiftUpdateCheck = 0720;
+				LastSwiftUpdateCheck = 0820;
 				LastUpgradeCheck = 0810;
 				LastUpgradeCheck = 0810;
 				ORGANIZATIONNAME = musevisions;
 				ORGANIZATIONNAME = musevisions;
 				TargetAttributes = {
 				TargetAttributes = {
@@ -391,6 +484,7 @@
 					};
 					};
 					6502F5FD1B985833004E342D = {
 					6502F5FD1B985833004E342D = {
 						CreatedOnToolsVersion = 7.0;
 						CreatedOnToolsVersion = 7.0;
+						DevelopmentTeam = M54ZVB688G;
 						LastSwiftMigration = 0800;
 						LastSwiftMigration = 0800;
 						ProvisioningStyle = Automatic;
 						ProvisioningStyle = Automatic;
 					};
 					};
@@ -398,6 +492,11 @@
 						CreatedOnToolsVersion = 7.0;
 						CreatedOnToolsVersion = 7.0;
 						LastSwiftMigration = 0800;
 						LastSwiftMigration = 0800;
 					};
 					};
+					658A083D1E2EC5120074A98F = {
+						CreatedOnToolsVersion = 8.2.1;
+						ProvisioningStyle = Automatic;
+						TestTargetID = 6502F5FD1B985833004E342D;
+					};
 					C4D74BBA1C24CEC90071AD3E = {
 					C4D74BBA1C24CEC90071AD3E = {
 						CreatedOnToolsVersion = 7.2;
 						CreatedOnToolsVersion = 7.2;
 					};
 					};
@@ -425,6 +524,7 @@
 				54C0D52B1CF7404500F90BCE /* SwiftyStoreKit_tvOS */,
 				54C0D52B1CF7404500F90BCE /* SwiftyStoreKit_tvOS */,
 				6502F5FD1B985833004E342D /* SwiftyStoreKit_iOSDemo */,
 				6502F5FD1B985833004E342D /* SwiftyStoreKit_iOSDemo */,
 				C4FD3A001C2954C10035CFF3 /* SwiftyStoreKit_macOSDemo */,
 				C4FD3A001C2954C10035CFF3 /* SwiftyStoreKit_macOSDemo */,
+				658A083D1E2EC5120074A98F /* SwiftyStoreKitTests */,
 			);
 			);
 		};
 		};
 /* End PBXProject section */
 /* End PBXProject section */
@@ -454,6 +554,13 @@
 			);
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 			runOnlyForDeploymentPostprocessing = 0;
 		};
 		};
+		658A083C1E2EC5120074A98F /* Resources */ = {
+			isa = PBXResourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
 		C4D74BB91C24CEC90071AD3E /* Resources */ = {
 		C4D74BB91C24CEC90071AD3E /* Resources */ = {
 			isa = PBXResourcesBuildPhase;
 			isa = PBXResourcesBuildPhase;
 			buildActionMask = 2147483647;
 			buildActionMask = 2147483647;
@@ -477,14 +584,17 @@
 			isa = PBXSourcesBuildPhase;
 			isa = PBXSourcesBuildPhase;
 			buildActionMask = 2147483647;
 			buildActionMask = 2147483647;
 			files = (
 			files = (
-				54B069911CF742CE00BAFE38 /* InAppCompleteTransactionsObserver.swift in Sources */,
 				1592CD521E27756500D321E6 /* AppleReceiptValidator.swift in Sources */,
 				1592CD521E27756500D321E6 /* AppleReceiptValidator.swift in Sources */,
-				54B069951CF742D900BAFE38 /* InAppProductPurchaseRequest.swift in Sources */,
 				54C0D5681CF7428400F90BCE /* SwiftyStoreKit.swift in Sources */,
 				54C0D5681CF7428400F90BCE /* SwiftyStoreKit.swift in Sources */,
 				54B069961CF744DC00BAFE38 /* OS.swift in Sources */,
 				54B069961CF744DC00BAFE38 /* OS.swift in Sources */,
 				54B069931CF742D300BAFE38 /* InAppReceiptRefreshRequest.swift in Sources */,
 				54B069931CF742D300BAFE38 /* InAppReceiptRefreshRequest.swift in Sources */,
+				65F70ACB1E2EDC3700BF040D /* PaymentsController.swift in Sources */,
+				650307FE1E33154F001332A4 /* ProductsInfoController.swift in Sources */,
+				650307F61E3177EF001332A4 /* RestorePurchasesController.swift in Sources */,
+				658A08391E2EC24E0074A98F /* PaymentQueueController.swift in Sources */,
 				653722831DB8290B00C8F944 /* SKProduct+LocalizedPrice.swift in Sources */,
 				653722831DB8290B00C8F944 /* SKProduct+LocalizedPrice.swift in Sources */,
 				54B069921CF742D100BAFE38 /* InAppReceipt.swift in Sources */,
 				54B069921CF742D100BAFE38 /* InAppReceipt.swift in Sources */,
+				650307FA1E317BCF001332A4 /* CompleteTransactionsController.swift in Sources */,
 				65BB6CEA1DDB018900218A0B /* SwiftyStoreKit+Types.swift in Sources */,
 				65BB6CEA1DDB018900218A0B /* SwiftyStoreKit+Types.swift in Sources */,
 				54B069941CF742D600BAFE38 /* InAppProductQueryRequest.swift in Sources */,
 				54B069941CF742D600BAFE38 /* InAppProductQueryRequest.swift in Sources */,
 			);
 			);
@@ -506,29 +616,50 @@
 			files = (
 			files = (
 				C40C68101C29414C00B60B7E /* OS.swift in Sources */,
 				C40C68101C29414C00B60B7E /* OS.swift in Sources */,
 				1592CD501E27756500D321E6 /* AppleReceiptValidator.swift in Sources */,
 				1592CD501E27756500D321E6 /* AppleReceiptValidator.swift in Sources */,
-				651A71251CD651AF000B4091 /* InAppCompleteTransactionsObserver.swift in Sources */,
-				6502F63A1B985C9E004E342D /* InAppProductPurchaseRequest.swift in Sources */,
 				6502F63B1B985CA1004E342D /* InAppProductQueryRequest.swift in Sources */,
 				6502F63B1B985CA1004E342D /* InAppProductQueryRequest.swift in Sources */,
 				C4083C571C2AB0A900295248 /* InAppReceiptRefreshRequest.swift in Sources */,
 				C4083C571C2AB0A900295248 /* InAppReceiptRefreshRequest.swift in Sources */,
+				65F70AC91E2EDC3700BF040D /* PaymentsController.swift in Sources */,
+				650307FC1E33154F001332A4 /* ProductsInfoController.swift in Sources */,
+				650307F41E3177EF001332A4 /* RestorePurchasesController.swift in Sources */,
+				658A08371E2EC24E0074A98F /* PaymentQueueController.swift in Sources */,
 				653722811DB8282600C8F944 /* SKProduct+LocalizedPrice.swift in Sources */,
 				653722811DB8282600C8F944 /* SKProduct+LocalizedPrice.swift in Sources */,
 				C4A7C7631C29B8D00053ED64 /* InAppReceipt.swift in Sources */,
 				C4A7C7631C29B8D00053ED64 /* InAppReceipt.swift in Sources */,
+				650307F81E317BCF001332A4 /* CompleteTransactionsController.swift in Sources */,
 				65BB6CE81DDB018900218A0B /* SwiftyStoreKit+Types.swift in Sources */,
 				65BB6CE81DDB018900218A0B /* SwiftyStoreKit+Types.swift in Sources */,
 				6502F63C1B985CA4004E342D /* SwiftyStoreKit.swift in Sources */,
 				6502F63C1B985CA4004E342D /* SwiftyStoreKit.swift in Sources */,
 			);
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 			runOnlyForDeploymentPostprocessing = 0;
 		};
 		};
+		658A083A1E2EC5120074A98F /* Sources */ = {
+			isa = PBXSourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				C3099C071E2FCDAA00392A54 /* PaymentsControllerTests.swift in Sources */,
+				650307F21E3163AA001332A4 /* RestorePurchasesControllerTests.swift in Sources */,
+				C3099C0B1E2FD13200392A54 /* TestPaymentTransaction.swift in Sources */,
+				65F70AC71E2ECBB300BF040D /* PaymentTransactionObserverFake.swift in Sources */,
+				658A084A1E2EC5350074A98F /* PaymentQueueControllerTests.swift in Sources */,
+				C3099C191E3206C700392A54 /* CompleteTransactionsControllerTests.swift in Sources */,
+				658A084C1E2EC5960074A98F /* PaymentQueueSpy.swift in Sources */,
+				C3099C091E2FCE3A00392A54 /* TestProduct.swift in Sources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
 		C4D74BB61C24CEC90071AD3E /* Sources */ = {
 		C4D74BB61C24CEC90071AD3E /* Sources */ = {
 			isa = PBXSourcesBuildPhase;
 			isa = PBXSourcesBuildPhase;
 			buildActionMask = 2147483647;
 			buildActionMask = 2147483647;
 			files = (
 			files = (
 				C40C68111C29419500B60B7E /* OS.swift in Sources */,
 				C40C68111C29419500B60B7E /* OS.swift in Sources */,
 				1592CD511E27756500D321E6 /* AppleReceiptValidator.swift in Sources */,
 				1592CD511E27756500D321E6 /* AppleReceiptValidator.swift in Sources */,
-				651A71261CD651AF000B4091 /* InAppCompleteTransactionsObserver.swift in Sources */,
-				C4D74BC31C24CEDC0071AD3E /* InAppProductPurchaseRequest.swift in Sources */,
 				C4D74BC41C24CEDC0071AD3E /* InAppProductQueryRequest.swift in Sources */,
 				C4D74BC41C24CEDC0071AD3E /* InAppProductQueryRequest.swift in Sources */,
 				C4F69A8A1C2E0D21009DD8BD /* InAppReceiptRefreshRequest.swift in Sources */,
 				C4F69A8A1C2E0D21009DD8BD /* InAppReceiptRefreshRequest.swift in Sources */,
+				65F70ACA1E2EDC3700BF040D /* PaymentsController.swift in Sources */,
+				650307FD1E33154F001332A4 /* ProductsInfoController.swift in Sources */,
+				650307F51E3177EF001332A4 /* RestorePurchasesController.swift in Sources */,
+				658A08381E2EC24E0074A98F /* PaymentQueueController.swift in Sources */,
 				653722821DB8290A00C8F944 /* SKProduct+LocalizedPrice.swift in Sources */,
 				653722821DB8290A00C8F944 /* SKProduct+LocalizedPrice.swift in Sources */,
 				C4083C551C2AADB500295248 /* InAppReceipt.swift in Sources */,
 				C4083C551C2AADB500295248 /* InAppReceipt.swift in Sources */,
+				650307F91E317BCF001332A4 /* CompleteTransactionsController.swift in Sources */,
 				65BB6CE91DDB018900218A0B /* SwiftyStoreKit+Types.swift in Sources */,
 				65BB6CE91DDB018900218A0B /* SwiftyStoreKit+Types.swift in Sources */,
 				C4D74BC51C24CEDC0071AD3E /* SwiftyStoreKit.swift in Sources */,
 				C4D74BC51C24CEDC0071AD3E /* SwiftyStoreKit.swift in Sources */,
 			);
 			);
@@ -546,6 +677,16 @@
 /* End PBXSourcesBuildPhase section */
 /* End PBXSourcesBuildPhase section */
 
 
 /* Begin PBXTargetDependency section */
 /* Begin PBXTargetDependency section */
+		658A08451E2EC5120074A98F /* PBXTargetDependency */ = {
+			isa = PBXTargetDependency;
+			target = 6502F62C1B985C40004E342D /* SwiftyStoreKit_iOS */;
+			targetProxy = 658A08441E2EC5120074A98F /* PBXContainerItemProxy */;
+		};
+		658A084E1E2EC83F0074A98F /* PBXTargetDependency */ = {
+			isa = PBXTargetDependency;
+			target = 6502F5FD1B985833004E342D /* SwiftyStoreKit_iOSDemo */;
+			targetProxy = 658A084D1E2EC83F0074A98F /* PBXContainerItemProxy */;
+		};
 		65F7DF911DCD524300835D30 /* PBXTargetDependency */ = {
 		65F7DF911DCD524300835D30 /* PBXTargetDependency */ = {
 			isa = PBXTargetDependency;
 			isa = PBXTargetDependency;
 			target = 6502F62C1B985C40004E342D /* SwiftyStoreKit_iOS */;
 			target = 6502F62C1B985C40004E342D /* SwiftyStoreKit_iOS */;
@@ -730,7 +871,7 @@
 				ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
 				ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
 				"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
-				DEVELOPMENT_TEAM = "";
+				DEVELOPMENT_TEAM = M54ZVB688G;
 				INFOPLIST_FILE = "$(SRCROOT)/SwiftyStoreKit-iOS-Demo/Info.plist";
 				INFOPLIST_FILE = "$(SRCROOT)/SwiftyStoreKit-iOS-Demo/Info.plist";
 				IPHONEOS_DEPLOYMENT_TARGET = 8.0;
 				IPHONEOS_DEPLOYMENT_TARGET = 8.0;
 				LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
 				LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
@@ -746,7 +887,7 @@
 				ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
 				ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
 				"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
-				DEVELOPMENT_TEAM = "";
+				DEVELOPMENT_TEAM = M54ZVB688G;
 				INFOPLIST_FILE = "$(SRCROOT)/SwiftyStoreKit-iOS-Demo/Info.plist";
 				INFOPLIST_FILE = "$(SRCROOT)/SwiftyStoreKit-iOS-Demo/Info.plist";
 				IPHONEOS_DEPLOYMENT_TARGET = 8.0;
 				IPHONEOS_DEPLOYMENT_TARGET = 8.0;
 				LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
 				LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
@@ -806,6 +947,38 @@
 			};
 			};
 			name = Release;
 			name = Release;
 		};
 		};
+		658A08471E2EC5120074A98F /* Debug */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				CLANG_ANALYZER_NONNULL = YES;
+				CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+				INFOPLIST_FILE = SwiftyStoreKitTests/Info.plist;
+				IPHONEOS_DEPLOYMENT_TARGET = 10.2;
+				LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks";
+				PRODUCT_BUNDLE_IDENTIFIER = com.musevisions.iOS.SwiftyStoreKitTests;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+				SWIFT_VERSION = 3.0;
+				TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SwiftyStoreKit_iOSDemo.app/SwiftyStoreKit_iOSDemo";
+			};
+			name = Debug;
+		};
+		658A08481E2EC5120074A98F /* Release */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				CLANG_ANALYZER_NONNULL = YES;
+				CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+				INFOPLIST_FILE = SwiftyStoreKitTests/Info.plist;
+				IPHONEOS_DEPLOYMENT_TARGET = 10.2;
+				LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks";
+				PRODUCT_BUNDLE_IDENTIFIER = com.musevisions.iOS.SwiftyStoreKitTests;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
+				SWIFT_VERSION = 3.0;
+				TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SwiftyStoreKit_iOSDemo.app/SwiftyStoreKit_iOSDemo";
+			};
+			name = Release;
+		};
 		C4D74BC01C24CECA0071AD3E /* Debug */ = {
 		C4D74BC01C24CECA0071AD3E /* Debug */ = {
 			isa = XCBuildConfiguration;
 			isa = XCBuildConfiguration;
 			buildSettings = {
 			buildSettings = {
@@ -927,6 +1100,15 @@
 			defaultConfigurationIsVisible = 0;
 			defaultConfigurationIsVisible = 0;
 			defaultConfigurationName = Release;
 			defaultConfigurationName = Release;
 		};
 		};
+		658A08461E2EC5120074A98F /* Build configuration list for PBXNativeTarget "SwiftyStoreKitTests" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				658A08471E2EC5120074A98F /* Debug */,
+				658A08481E2EC5120074A98F /* Release */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Release;
+		};
 		C4D74BC21C24CECA0071AD3E /* Build configuration list for PBXNativeTarget "SwiftyStoreKit_macOS" */ = {
 		C4D74BC21C24CECA0071AD3E /* Build configuration list for PBXNativeTarget "SwiftyStoreKit_macOS" */ = {
 			isa = XCConfigurationList;
 			isa = XCConfigurationList;
 			buildConfigurations = (
 			buildConfigurations = (

+ 10 - 0
SwiftyStoreKit.xcodeproj/xcshareddata/xcschemes/SwiftyStoreKit-iOS-Demo.xcscheme

@@ -28,6 +28,16 @@
       selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
       selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
       shouldUseLaunchSchemeArgsEnv = "YES">
       shouldUseLaunchSchemeArgsEnv = "YES">
       <Testables>
       <Testables>
+         <TestableReference
+            skipped = "NO">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "658A083D1E2EC5120074A98F"
+               BuildableName = "SwiftyStoreKitTests.xctest"
+               BlueprintName = "SwiftyStoreKitTests"
+               ReferencedContainer = "container:SwiftyStoreKit.xcodeproj">
+            </BuildableReference>
+         </TestableReference>
       </Testables>
       </Testables>
       <MacroExpansion>
       <MacroExpansion>
          <BuildableReference
          <BuildableReference

+ 56 - 0
SwiftyStoreKit.xcodeproj/xcshareddata/xcschemes/SwiftyStoreKitTests.xcscheme

@@ -0,0 +1,56 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Scheme
+   LastUpgradeVersion = "0820"
+   version = "1.3">
+   <BuildAction
+      parallelizeBuildables = "YES"
+      buildImplicitDependencies = "YES">
+   </BuildAction>
+   <TestAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      shouldUseLaunchSchemeArgsEnv = "YES">
+      <Testables>
+         <TestableReference
+            skipped = "NO">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "658A083D1E2EC5120074A98F"
+               BuildableName = "SwiftyStoreKitTests.xctest"
+               BlueprintName = "SwiftyStoreKitTests"
+               ReferencedContainer = "container:SwiftyStoreKit.xcodeproj">
+            </BuildableReference>
+         </TestableReference>
+      </Testables>
+      <AdditionalOptions>
+      </AdditionalOptions>
+   </TestAction>
+   <LaunchAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      launchStyle = "0"
+      useCustomWorkingDirectory = "NO"
+      ignoresPersistentStateOnLaunch = "NO"
+      debugDocumentVersioning = "YES"
+      debugServiceExtension = "internal"
+      allowLocationSimulation = "YES">
+      <AdditionalOptions>
+      </AdditionalOptions>
+   </LaunchAction>
+   <ProfileAction
+      buildConfiguration = "Release"
+      shouldUseLaunchSchemeArgsEnv = "YES"
+      savedToolIdentifier = ""
+      useCustomWorkingDirectory = "NO"
+      debugDocumentVersioning = "YES">
+   </ProfileAction>
+   <AnalyzeAction
+      buildConfiguration = "Debug">
+   </AnalyzeAction>
+   <ArchiveAction
+      buildConfiguration = "Release"
+      revealArchiveInOrganizer = "YES">
+   </ArchiveAction>
+</Scheme>

+ 10 - 0
SwiftyStoreKit.xcodeproj/xcuserdata/andrea.xcuserdatad/xcschemes/xcschememanagement.plist

@@ -39,6 +39,11 @@
 			<key>orderHint</key>
 			<key>orderHint</key>
 			<integer>1</integer>
 			<integer>1</integer>
 		</dict>
 		</dict>
+		<key>SwiftyStoreKitTests.xcscheme_^#shared#^_</key>
+		<dict>
+			<key>orderHint</key>
+			<integer>5</integer>
+		</dict>
 	</dict>
 	</dict>
 	<key>SuppressBuildableAutocreation</key>
 	<key>SuppressBuildableAutocreation</key>
 	<dict>
 	<dict>
@@ -57,6 +62,11 @@
 			<key>primary</key>
 			<key>primary</key>
 			<true/>
 			<true/>
 		</dict>
 		</dict>
+		<key>658A083D1E2EC5120074A98F</key>
+		<dict>
+			<key>primary</key>
+			<true/>
+		</dict>
 		<key>C4D74BBA1C24CEC90071AD3E</key>
 		<key>C4D74BBA1C24CEC90071AD3E</key>
 		<dict>
 		<dict>
 			<key>primary</key>
 			<key>primary</key>

+ 35 - 45
SwiftyStoreKit/InAppCompleteTransactionsObserver.swift → SwiftyStoreKit/CompleteTransactionsController.swift

@@ -1,8 +1,8 @@
 //
 //
-// InAppCompleteTransactionsObserver.swift
+// CompleteTransactionsController.swift
 // SwiftyStoreKit
 // SwiftyStoreKit
 //
 //
-// Copyright (c) 2016 Andrea Bizzotto (bizz84@gmail.com)
+// Copyright (c) 2017 Andrea Bizzotto (bizz84@gmail.com)
 //
 //
 // Permission is hereby granted, free of charge, to any person obtaining a copy
 // Permission is hereby granted, free of charge, to any person obtaining a copy
 // of this software and associated documentation files (the "Software"), to deal
 // of this software and associated documentation files (the "Software"), to deal
@@ -22,78 +22,68 @@
 // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 // THE SOFTWARE.
 // THE SOFTWARE.
 
 
-
+import Foundation
 import StoreKit
 import StoreKit
 
 
+struct CompleteTransactions {
+    let atomically: Bool
+    let callback: ([Product]) -> ()
+    
+    init(atomically: Bool, callback: @escaping ([Product]) -> ()) {
+        self.atomically = atomically
+        self.callback = callback
+    }
+}
+
 extension SKPaymentTransactionState {
 extension SKPaymentTransactionState {
     
     
     var stringValue: String {
     var stringValue: String {
         switch self {
         switch self {
-        case .purchasing: return "Purchasing"
-        case .purchased: return "Purchased"
-        case .failed: return "Failed"
-        case .restored: return "Restored"
-        case .deferred: return "Deferred"
+        case .purchasing: return "purchasing"
+        case .purchased: return "purchased"
+        case .failed: return "failed"
+        case .restored: return "restored"
+        case .deferred: return "deferred"
         }
         }
     }
     }
 }
 }
 
 
-class InAppCompleteTransactionsObserver: NSObject, SKPaymentTransactionObserver {
-    
-    private var callbackCalled: Bool = false
-        
-    typealias TransactionsCallback = ([Product]) -> ()
-    
-    var paymentQueue: SKPaymentQueue {
-        return SKPaymentQueue.default()
-    }
 
 
-    let atomically: Bool
-    
-    deinit {
-        paymentQueue.remove(self)
-    }
+class CompleteTransactionsController: TransactionController {
 
 
-    let callback: TransactionsCallback
+    var completeTransactions: CompleteTransactions?
     
     
-    init(atomically: Bool, callback: @escaping TransactionsCallback) {
-    
-        self.atomically = atomically
-        self.callback = callback
-        super.init()
-        paymentQueue.add(self)
-    }
-
-    func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
+    func processTransactions(_ transactions: [SKPaymentTransaction], on paymentQueue: PaymentQueue) -> [SKPaymentTransaction] {
         
         
-        if callbackCalled {
-            return
+        guard let completeTransactions = completeTransactions else {
+            return transactions
         }
         }
-        if SwiftyStoreKit.hasInFlightPayments {
-            return
-        }
-        
-        var completedTransactions: [Product] = []
+
+        var unhandledTransactions: [SKPaymentTransaction] = []
+        var products: [Product] = []
         
         
         for transaction in transactions {
         for transaction in transactions {
             
             
             let transactionState = transaction.transactionState
             let transactionState = transaction.transactionState
-
+            
             if transactionState != .purchasing {
             if transactionState != .purchasing {
                 
                 
-                let product = Product(productId: transaction.payment.productIdentifier, transaction: transaction, needsFinishTransaction: !atomically)
+                let product = Product(productId: transaction.payment.productIdentifier, transaction: transaction, needsFinishTransaction: !completeTransactions.atomically)
                 
                 
-                completedTransactions.append(product)
+                products.append(product)
                 
                 
                 print("Finishing transaction for payment \"\(transaction.payment.productIdentifier)\" with state: \(transactionState.stringValue)")
                 print("Finishing transaction for payment \"\(transaction.payment.productIdentifier)\" with state: \(transactionState.stringValue)")
                 
                 
-                if atomically {
+                if completeTransactions.atomically {
                     paymentQueue.finishTransaction(transaction)
                     paymentQueue.finishTransaction(transaction)
                 }
                 }
             }
             }
+            else {
+                unhandledTransactions.append(transaction)
+            }
         }
         }
-        callbackCalled = true
+        completeTransactions.callback(products)
 
 
-        callback(completedTransactions)
+        return unhandledTransactions
     }
     }
 }
 }

+ 0 - 184
SwiftyStoreKit/InAppProductPurchaseRequest.swift

@@ -1,184 +0,0 @@
-//
-// InAppProductPurchaseRequest.swift
-// SwiftyStoreKit
-//
-// Copyright (c) 2015 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 StoreKit
-import Foundation
-
-class InAppProductPurchaseRequest: NSObject, SKPaymentTransactionObserver {
-
-    enum TransactionResult {
-        case purchased(product: Product)
-        case restored(product: Product)
-        case failed(error: Error)
-    }
-    
-    typealias RequestCallback = ([TransactionResult]) -> ()
-    private let callback: RequestCallback
-    private var purchases : [SKPaymentTransactionState: [String]] = [:]
-    
-    var paymentQueue: SKPaymentQueue {
-        return SKPaymentQueue.default()
-    }
-    
-    let product : SKProduct?
-    let atomically: Bool
-    
-    deinit {
-        paymentQueue.remove(self)
-    }
-    // Initialiser for product purchase
-    private init(product: SKProduct?, atomically: Bool, callback: @escaping RequestCallback) {
-
-        self.atomically = atomically
-        self.product = product
-        self.callback = callback
-        super.init()
-        paymentQueue.add(self)
-    }
-    // MARK: Public methods
-    class func startPayment(product: SKProduct, atomically: Bool, applicationUsername: String = "", callback: @escaping RequestCallback) -> InAppProductPurchaseRequest {
-        let request = InAppProductPurchaseRequest(product: product, atomically: atomically, callback: callback)
-        request.startPayment(product, applicationUsername: applicationUsername)
-        return request
-    }
-    class func restorePurchases(atomically: Bool, callback: @escaping RequestCallback) -> InAppProductPurchaseRequest {
-        let request = InAppProductPurchaseRequest(product: nil, atomically: atomically, callback: callback)
-        request.startRestorePurchases()
-        return request
-    }
-    
-    class func finishTransaction(_ transaction: PaymentTransaction) {
-        guard let skTransaction = transaction as? SKPaymentTransaction else {
-            print("Object is not a SKPaymentTransaction: \(transaction)")
-            return
-        }
-        SKPaymentQueue.default().finishTransaction(skTransaction)
-    }
-    
-    // MARK: Private methods
-    private func startPayment(_ product: SKProduct, applicationUsername: String = "") {
-        let payment = SKMutablePayment(product: product)
-        payment.applicationUsername = applicationUsername
-        
-        DispatchQueue.global(qos: .default).async {
-            self.paymentQueue.add(payment)
-        }
-    }
-    private func startRestorePurchases() {
-        
-        DispatchQueue.global(qos: .default).async {
-            self.paymentQueue.restoreCompletedTransactions()
-        }
-    }
-        
-    // MARK: SKPaymentTransactionObserver
-    func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
-        
-        var transactionResults: [TransactionResult] = []
-        
-        for transaction in transactions {
-            
-            let transactionProductIdentifier = transaction.payment.productIdentifier
-            
-            var isPurchaseRequest = false
-            if let productIdentifier = product?.productIdentifier {
-                if transactionProductIdentifier != productIdentifier {
-                    continue
-                }
-                isPurchaseRequest = true
-            }
-
-            let transactionState = transaction.transactionState
-
-            switch transactionState {
-            case .purchased:
-                if isPurchaseRequest {
-                    let product = Product(productId: transactionProductIdentifier, transaction: transaction, needsFinishTransaction: !atomically)
-                    transactionResults.append(.purchased(product: product))
-                    if atomically {
-                        paymentQueue.finishTransaction(transaction)
-                    }
-                }
-            case .failed:
-                // TODO: How to discriminate between purchase and restore?
-                // It appears that in some edge cases transaction.error is nil here. Since returning an associated error is
-                // mandatory, return a default one if needed
-                let message = "Transaction failed for product ID: \(transactionProductIdentifier)"
-                let altError = NSError(domain: SKErrorDomain, code: 0, userInfo: [ NSLocalizedDescriptionKey: message ])
-                transactionResults.append(.failed(error: transaction.error ?? altError))
-                paymentQueue.finishTransaction(transaction)
-            case .restored:
-                if !isPurchaseRequest {
-                    let product = Product(productId: transactionProductIdentifier, transaction: transaction, needsFinishTransaction: !atomically)
-                    transactionResults.append(.restored(product: product))
-                    if atomically {
-                        paymentQueue.finishTransaction(transaction)
-                    }
-                }
-            case .purchasing:
-                // In progress: do nothing
-                break
-            case .deferred:
-                break
-            }
-            // Keep track of payments
-            if let _ = purchases[transactionState] {
-                purchases[transactionState]?.append(transactionProductIdentifier)
-            }
-            else {
-                purchases[transactionState] = [ transactionProductIdentifier ]
-            }
-        }
-        if transactionResults.count > 0 {
-            DispatchQueue.main.async {
-                self.callback(transactionResults)
-            }
-        }
-    }
-    
-    func paymentQueue(_ queue: SKPaymentQueue, removedTransactions transactions: [SKPaymentTransaction]) {
-        
-    }
-    
-    func paymentQueue(_ queue: SKPaymentQueue, restoreCompletedTransactionsFailedWithError error: Error) {
-        
-        DispatchQueue.main.async {
-            self.callback([.failed(error: error)])
-        }
-    }
-
-    func paymentQueueRestoreCompletedTransactionsFinished(_ queue: SKPaymentQueue) {
-        // This method will be called after all purchases have been restored (includes the case of no purchases)
-        guard let restored = purchases[.restored], restored.count > 0 else {
-            
-            self.callback([])
-            return
-        }
-    }
-    
-    func paymentQueue(_ queue: SKPaymentQueue, updatedDownloads downloads: [SKDownload]) {
-        
-    }
-}
-

+ 2 - 6
SwiftyStoreKit/InAppProductQueryRequest.swift

@@ -48,14 +48,10 @@ class InAppProductQueryRequest: NSObject, SKProductsRequestDelegate {
     }
     }
 
 
     func start() {
     func start() {
-        DispatchQueue.global(qos: .default).async {
-            self.request.start()
-        }
+        self.request.start()
     }
     }
     func cancel() {
     func cancel() {
-        DispatchQueue.global(qos: .default).async {
-            self.request.cancel()
-        }
+        self.request.cancel()
     }
     }
     
     
     // MARK: SKProductsRequestDelegate
     // MARK: SKProductsRequestDelegate

+ 173 - 0
SwiftyStoreKit/PaymentQueueController.swift

@@ -0,0 +1,173 @@
+//
+// PaymentQueueController.swift
+// SwiftyStoreKit
+//
+// 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
+import StoreKit
+
+protocol TransactionController {
+    
+    /**
+     * - param transactions: transactions to process
+     * - param paymentQueue: payment queue for finishing transactions
+     * - return: array of unhandled transactions
+     */
+    func processTransactions(_ transactions: [SKPaymentTransaction], on paymentQueue: PaymentQueue) -> [SKPaymentTransaction]
+}
+
+public enum TransactionResult {
+    case purchased(product: Product)
+    case restored(product: Product)
+    case failed(error: Error)
+}
+
+public protocol PaymentQueue: class {
+
+    func add(_ observer: SKPaymentTransactionObserver)
+    func remove(_ observer: SKPaymentTransactionObserver)
+
+    func add(_ payment: SKPayment)
+    
+    func restoreCompletedTransactions(withApplicationUsername username: String?)
+    
+    func finishTransaction(_ transaction: SKPaymentTransaction)
+}
+
+extension SKPaymentQueue: PaymentQueue { }
+
+class PaymentQueueController: NSObject, SKPaymentTransactionObserver {
+    
+    private let paymentsController: PaymentsController
+    
+    private let restorePurchasesController: RestorePurchasesController
+    
+    private let completeTransactionsController: CompleteTransactionsController
+    
+    unowned let paymentQueue: PaymentQueue
+
+    deinit {
+        paymentQueue.remove(self)
+    }
+
+    init(paymentQueue: PaymentQueue = SKPaymentQueue.default(),
+                paymentsController: PaymentsController = PaymentsController(),
+                restorePurchasesController: RestorePurchasesController = RestorePurchasesController(),
+                completeTransactionsController: CompleteTransactionsController = CompleteTransactionsController()) {
+     
+        self.paymentQueue = paymentQueue
+        self.paymentsController = paymentsController
+        self.restorePurchasesController = restorePurchasesController
+        self.completeTransactionsController = completeTransactionsController
+        super.init()
+        paymentQueue.add(self)
+    }
+    
+    func startPayment(_ payment: Payment) {
+        
+        let skPayment = SKMutablePayment(product: payment.product)
+        skPayment.applicationUsername = payment.applicationUsername
+        paymentQueue.add(skPayment)
+        
+        paymentsController.append(payment)
+    }
+    
+    func restorePurchases(_ restorePurchases: RestorePurchases) {
+        
+        if restorePurchasesController.restorePurchases != nil {
+            // return .inProgress
+            return
+        }
+        
+        paymentQueue.restoreCompletedTransactions(withApplicationUsername: restorePurchases.applicationUsername)
+        
+        restorePurchasesController.restorePurchases = restorePurchases
+    }
+    
+    func completeTransactions(_ completeTransactions: CompleteTransactions) {
+        
+        completeTransactionsController.completeTransactions = completeTransactions
+    }
+    
+    func finishTransaction(_ transaction: PaymentTransaction) {
+        guard let skTransaction = transaction as? SKPaymentTransaction else {
+            print("Object is not a SKPaymentTransaction: \(transaction)")
+            return
+        }
+        paymentQueue.finishTransaction(skTransaction)
+    }
+
+    
+    // MARK: SKPaymentTransactionObserver
+    func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
+        
+        /*
+         * Some notes about how requests are processed by SKPaymentQueue:
+         *
+         * SKPaymentQueue is used to queue payments or restore purchases requests.
+         * Payments are processed serially and in-order and require user interaction.
+         * Restore purchases requests don't require user interaction and can jump ahead of the queue.
+         * SKPaymentQueue rejects multiple restore purchases calls.
+         * Having one payment queue observer for each request causes extra processing
+         * Failed translations only ever belong to queued payment request.
+         * restoreCompletedTransactionsFailedWithError is always called when a restore purchases request fails.
+         * paymentQueueRestoreCompletedTransactionsFinished is always called following 0 or more update transactions when a restore purchases request succeeds.
+         * A complete transactions handler is require to catch any transactions that are updated when the app is not running.
+         * Registering a complete transactions handler when the app launches ensures that any pending transactions can be cleared.
+         * If a complete transactions handler is missing, pending transactions can be mis-attributed to any new incoming payments or restore purchases.
+         * 
+         * The order in which transaction updates are processed is:
+         * 1. payments (transactionState: .purchased and .failed for matching product identifiers)
+         * 2. restore purchases (transactionState: .restored, or restoreCompletedTransactionsFailedWithError, or paymentQueueRestoreCompletedTransactionsFinished)
+         * 3. complete transactions (transactionState: .purchased, .failed, .restored, .deferred)
+         * Any transactions where state == .purchasing are ignored.
+         */
+        var unhandledTransactions = paymentsController.processTransactions(transactions, on: paymentQueue)
+        
+        unhandledTransactions = restorePurchasesController.processTransactions(unhandledTransactions, on: paymentQueue)
+        
+        unhandledTransactions = completeTransactionsController.processTransactions(unhandledTransactions, on: paymentQueue)
+        
+        if unhandledTransactions.count > 0 {
+            print("unhandledTransactions: \(unhandledTransactions)")
+        }
+    }
+    
+    func paymentQueue(_ queue: SKPaymentQueue, removedTransactions transactions: [SKPaymentTransaction]) {
+        
+    }
+    
+    func paymentQueue(_ queue: SKPaymentQueue, restoreCompletedTransactionsFailedWithError error: Error) {
+        
+        restorePurchasesController.restoreCompletedTransactionsFailed(withError: error)
+    }
+    
+    func paymentQueueRestoreCompletedTransactionsFinished(_ queue: SKPaymentQueue) {
+
+        restorePurchasesController.restoreCompletedTransactionsFinished()
+    }
+    
+    func paymentQueue(_ queue: SKPaymentQueue, updatedDownloads downloads: [SKDownload]) {
+        
+    }
+
+}

+ 110 - 0
SwiftyStoreKit/PaymentsController.swift

@@ -0,0 +1,110 @@
+//
+// PaymentsController.swift
+// SwiftyStoreKit
+//
+// 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
+import StoreKit
+
+struct Payment: Hashable {
+    let product: SKProduct
+    let atomically: Bool
+    let applicationUsername: String
+    let callback: (TransactionResult) -> ()
+    
+    var hashValue: Int {
+        return product.productIdentifier.hashValue
+    }
+    static func ==(lhs: Payment, rhs: Payment) -> Bool {
+        return lhs.product.productIdentifier == rhs.product.productIdentifier
+    }
+}
+
+class PaymentsController: TransactionController {
+    
+    private var payments: [Payment] = []
+    
+    private func findPaymentIndex(withProductIdentifier identifier: String) -> Int? {
+        for payment in payments {
+            if payment.product.productIdentifier == identifier {
+                return payments.index(of: payment)
+            }
+        }
+        return nil
+    }
+    
+    func hasPayment(_ payment: Payment) -> Bool {
+        return findPaymentIndex(withProductIdentifier: payment.product.productIdentifier) != nil
+    }
+    
+    func append(_ payment: Payment) {
+        payments.append(payment)
+    }
+    
+    func processTransaction(_ transaction: SKPaymentTransaction, on paymentQueue: PaymentQueue) -> Bool {
+        
+        let transactionProductIdentifier = transaction.payment.productIdentifier
+        
+        guard let paymentIndex = findPaymentIndex(withProductIdentifier: transactionProductIdentifier) else {
+
+            return false
+        }
+        let payment = payments[paymentIndex]
+        
+        let transactionState = transaction.transactionState
+        
+        if transactionState == .purchased {
+
+            let product = Product(productId: transactionProductIdentifier, transaction: transaction, needsFinishTransaction: !payment.atomically)
+            
+            payment.callback(.purchased(product: product))
+            
+            if payment.atomically {
+                paymentQueue.finishTransaction(transaction)
+            }
+            payments.remove(at: paymentIndex)
+            return true
+        }
+        if transactionState == .failed {
+
+            let message = "Transaction failed for product ID: \(transactionProductIdentifier)"
+            let altError = NSError(domain: SKErrorDomain, code: 0, userInfo: [ NSLocalizedDescriptionKey: message ])
+            payment.callback(.failed(error: transaction.error ?? altError))
+            
+            paymentQueue.finishTransaction(transaction)
+            payments.remove(at: paymentIndex)
+            return true
+        }
+        
+        if transactionState == .restored {
+            print("Unexpected restored transaction for payment \(transactionProductIdentifier)")
+        }
+        return false
+    }
+    
+    func processTransactions(_ transactions: [SKPaymentTransaction], on paymentQueue: PaymentQueue) -> [SKPaymentTransaction] {
+        
+        return transactions.filter { !processTransaction($0, on: paymentQueue) }
+    }
+}
+

+ 69 - 0
SwiftyStoreKit/ProductsInfoController.swift

@@ -0,0 +1,69 @@
+//
+// ProductsInfoController.swift
+// SwiftyStoreKit
+//
+// Copyright (c) 2015 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
+import StoreKit
+
+class ProductsInfoController: NSObject {
+
+    // MARK: Private declarations
+
+    // As we can have multiple inflight queries and purchases, we store them in a dictionary by product id
+    private var inflightQueries: [Set<String>: InAppProductQueryRequest] = [:]
+
+    private(set) var products: [String: SKProduct] = [:]
+
+    private func addProduct(_ product: SKProduct) {
+        products[product.productIdentifier] = product
+    }
+    
+    private func allProductsMatching(_ productIds: Set<String>) -> Set<SKProduct> {
+        
+        return Set(productIds.flatMap { self.products[$0] })
+    }
+    
+    private func requestProducts(_ productIds: Set<String>, completion: @escaping (RetrieveResults) -> ()) {
+        
+        inflightQueries[productIds] = InAppProductQueryRequest.startQuery(productIds) { result in
+            
+            self.inflightQueries[productIds] = nil
+            for product in result.retrievedProducts {
+                self.addProduct(product)
+            }
+            completion(result)
+        }
+    }
+    
+    func retrieveProductsInfo(_ productIds: Set<String>, completion: @escaping (RetrieveResults) -> ()) {
+        
+        let products = allProductsMatching(productIds)
+        guard products.count == productIds.count else {
+            
+            requestProducts(productIds, completion: completion)
+            return
+        }
+        completion(RetrieveResults(retrievedProducts: products, invalidProductIDs: [], error: nil))
+    }
+
+}

+ 107 - 0
SwiftyStoreKit/RestorePurchasesController.swift

@@ -0,0 +1,107 @@
+//
+// RestorePurchasesController.swift
+// SwiftyStoreKit
+//
+// 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
+import StoreKit
+
+struct RestorePurchases {
+    let atomically: Bool
+    let applicationUsername: String?
+    let callback: ([TransactionResult]) -> ()
+    
+    init(atomically: Bool, applicationUsername: String? = nil, callback: @escaping ([TransactionResult]) -> ()) {
+        self.atomically = atomically
+        self.applicationUsername = applicationUsername
+        self.callback = callback
+    }
+}
+
+class RestorePurchasesController: TransactionController {
+    
+    public var restorePurchases: RestorePurchases?
+    
+    private var restoredProducts: [TransactionResult] = []
+    
+    func processTransaction(_ transaction: SKPaymentTransaction, atomically: Bool, on paymentQueue: PaymentQueue) -> Product? {
+        
+        let transactionState = transaction.transactionState
+        
+        if transactionState == .restored {
+            
+            let transactionProductIdentifier = transaction.payment.productIdentifier
+            
+            let product = Product(productId: transactionProductIdentifier, transaction: transaction, needsFinishTransaction: !atomically)
+            if atomically {
+                paymentQueue.finishTransaction(transaction)
+            }
+            return product
+        }
+        return nil
+    }
+    
+    func processTransactions(_ transactions: [SKPaymentTransaction], on paymentQueue: PaymentQueue) -> [SKPaymentTransaction] {
+        
+        guard let restorePurchases = restorePurchases else {
+            return transactions
+        }
+        
+        var unhandledTransactions: [SKPaymentTransaction] = []
+        for transaction in transactions {
+            if let restoredProduct = processTransaction(transaction, atomically: restorePurchases.atomically, on: paymentQueue) {
+                restoredProducts.append(.restored(product: restoredProduct))
+            }
+            else {
+                unhandledTransactions.append(transaction)
+            }
+        }
+
+        return unhandledTransactions
+    }
+    
+    func restoreCompletedTransactionsFailed(withError error: Error) {
+        
+        guard let restorePurchases = restorePurchases else {
+            return
+        }
+        restoredProducts.append(.failed(error: error))
+        restorePurchases.callback(restoredProducts)
+        
+        // Reset state after error received
+        restoredProducts = []
+        self.restorePurchases = nil
+
+    }
+    
+    func restoreCompletedTransactionsFinished() {
+        
+        guard let restorePurchases = restorePurchases else {
+            return
+        }
+        restorePurchases.callback(restoredProducts)
+        
+        // Reset state after error transactions finished
+        restoredProducts = []
+        self.restorePurchases = nil
+    }
+}

+ 145 - 144
SwiftyStoreKit/SwiftyStoreKit.swift

@@ -26,79 +26,39 @@ import StoreKit
 
 
 public class SwiftyStoreKit {
 public class SwiftyStoreKit {
 
 
-    // MARK: Private declarations
-    private class InAppPurchaseStore {
-        var products: [String: SKProduct] = [:]
-        func addProduct(_ product: SKProduct) {
-            products[product.productIdentifier] = product
-        }
-        func allProductsMatching(_ productIds: Set<String>) -> Set<SKProduct>? {
-            var requestedProducts = Set<SKProduct>()
-            for productId in productIds {
-                guard let product = products[productId] else {
-                    return nil
-                }
-                requestedProducts.insert(product)
-            }
-            return requestedProducts
-        }
-    }
-    private var store: InAppPurchaseStore = InAppPurchaseStore()
+    private let productsInfoController: ProductsInfoController
 
 
-    // As we can have multiple inflight queries and purchases, we store them in a dictionary by product id
-    private var inflightQueries: [Set<String>: InAppProductQueryRequest] = [:]
-    private var inflightPurchases: [String: InAppProductPurchaseRequest] = [:]
-    private var restoreRequest: InAppProductPurchaseRequest?
-    private var completeTransactionsObserver: InAppCompleteTransactionsObserver?
+    private let paymentQueueController: PaymentQueueController
+    
     private var receiptRefreshRequest: InAppReceiptRefreshRequest?
     private var receiptRefreshRequest: InAppReceiptRefreshRequest?
     
     
     private enum InternalErrorCode: Int {
     private enum InternalErrorCode: Int {
         case restoredPurchaseWhenPurchasing = 0
         case restoredPurchaseWhenPurchasing = 0
         case purchasedWhenRestoringPurchase = 1
         case purchasedWhenRestoringPurchase = 1
     }
     }
-
-    // MARK: Singleton
-    private static let sharedInstance = SwiftyStoreKit()
-    
-    public class var canMakePayments: Bool {
-        return SKPaymentQueue.canMakePayments()
-    }
     
     
-    class var hasInFlightPayments: Bool {
-        return sharedInstance.inflightPurchases.count > 0 || sharedInstance.restoreRequest != nil
+    init(productsInfoController: ProductsInfoController = ProductsInfoController(),
+         paymentQueueController: PaymentQueueController = PaymentQueueController(paymentQueue: SKPaymentQueue.default())) {
+        
+        self.productsInfoController = productsInfoController
+        self.paymentQueueController = paymentQueueController
     }
     }
+
+    // MARK: Internal methods
     
     
-    public class func completeTransactions(atomically: Bool = true, completion: @escaping ([Product]) -> ()) {
-        sharedInstance.completeTransactionsObserver = InAppCompleteTransactionsObserver(atomically: atomically, callback: completion)
+    func retrieveProductsInfo(_ productIds: Set<String>, completion: @escaping (RetrieveResults) -> ()) {
+        return productsInfoController.retrieveProductsInfo(productIds, completion: completion)
     }
     }
     
     
-    // MARK: Public methods
-    public class func retrieveProductsInfo(_ productIds: Set<String>, completion: @escaping (RetrieveResults) -> ()) {
+    func purchaseProduct(_ productId: String, atomically: Bool = true, applicationUsername: String = "", completion: @escaping ( PurchaseResult) -> ()) {
         
         
-        guard let products = sharedInstance.store.allProductsMatching(productIds) else {
-            
-            sharedInstance.requestProducts(productIds, completion: completion)
-            return
-        }
-        completion(RetrieveResults(retrievedProducts: products, invalidProductIDs: [], error: nil))
-    }
-    
-    /**
-     *  Purchase a product
-     *  - Parameter productId: productId as specified in iTunes Connect
-     *  - 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 purchaseProduct(_ productId: String, atomically: Bool = true, applicationUsername: String = "", completion: @escaping ( PurchaseResult) -> ()) {
-        
-        if let product = sharedInstance.store.products[productId] {
-            sharedInstance.purchase(product: product, atomically: atomically, applicationUsername: applicationUsername, completion: completion)
+        if let product = productsInfoController.products[productId] {
+            purchase(product: product, atomically: atomically, applicationUsername: applicationUsername, completion: completion)
         }
         }
         else {
         else {
             retrieveProductsInfo(Set([productId])) { result -> () in
             retrieveProductsInfo(Set([productId])) { result -> () in
                 if let product = result.retrievedProducts.first {
                 if let product = result.retrievedProducts.first {
-                    sharedInstance.purchase(product: product, atomically: atomically, applicationUsername: applicationUsername, completion: completion)
+                    self.purchase(product: product, atomically: atomically, applicationUsername: applicationUsername, completion: completion)
                 }
                 }
                 else if let error = result.error {
                 else if let error = result.error {
                     completion(.error(error: .failed(error: error)))
                     completion(.error(error: .failed(error: error)))
@@ -110,83 +70,30 @@ public class SwiftyStoreKit {
         }
         }
     }
     }
     
     
-    public class func restorePurchases(atomically: Bool = true, completion: @escaping (RestoreResults) -> ()) {
-
-        sharedInstance.restoreRequest = InAppProductPurchaseRequest.restorePurchases(atomically: atomically) { results in
+    func restorePurchases(atomically: Bool = true, applicationUsername: String = "", completion: @escaping (RestoreResults) -> ()) {
         
         
-            sharedInstance.restoreRequest = nil
-            let results = sharedInstance.processRestoreResults(results)
+        paymentQueueController.restorePurchases(RestorePurchases(atomically: atomically, applicationUsername: applicationUsername) { results in
+            
+            let results = self.processRestoreResults(results)
             completion(results)
             completion(results)
-        }
+        })
     }
     }
     
     
-    public class func finishTransaction(_ transaction: PaymentTransaction) {
-     
-        InAppProductPurchaseRequest.finishTransaction(transaction)
-    }
-
-    /**
-     * Return receipt data from the application bundle. This is read from Bundle.main.appStoreReceiptURL
-     */
-    public static var localReceiptData: Data? {
-        return InAppReceipt.appStoreReceiptData
+    func completeTransactions(atomically: Bool = true, completion: @escaping ([Product]) -> ()) {
+        
+        paymentQueueController.completeTransactions(CompleteTransactions(atomically: atomically, callback: completion))
     }
     }
     
     
-    /**
-     *  Verify application receipt
-     *  - 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) -> ()) {
-
-		InAppReceipt.verify(using: validator, password: password) { result in
-         
-            DispatchQueue.main.async {
-                completion(result)
-            }
-        }
-    }
-  
-    /**
-     *  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 {
-        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 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
-     */
-    public class func verifySubscription(
-        productId: String,
-        inReceipt receipt: ReceiptInfo,
-        validUntil date: Date = Date(),
-        validDuration duration: TimeInterval? = nil
-    ) -> VerifySubscriptionResult {
-        return InAppReceipt.verifySubscription(productId: productId, inReceipt: receipt, validUntil: date, validDuration: duration)
+    func finishTransaction(_ transaction: PaymentTransaction) {
+        
+        paymentQueueController.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) -> ()) {
-        sharedInstance.receiptRefreshRequest = InAppReceiptRefreshRequest.refresh(receiptProperties) { result in
-
-            sharedInstance.receiptRefreshRequest = nil
-
+    func refreshReceipt(_ receiptProperties: [String : Any]? = nil, completion: @escaping (RefreshReceiptResult) -> ()) {
+        receiptRefreshRequest = InAppReceiptRefreshRequest.refresh(receiptProperties) { result in
+            
+            self.receiptRefreshRequest = nil
+            
             switch result {
             switch result {
             case .success:
             case .success:
                 if let appStoreReceiptData = InAppReceipt.appStoreReceiptData {
                 if let appStoreReceiptData = InAppReceipt.appStoreReceiptData {
@@ -207,19 +114,14 @@ public class SwiftyStoreKit {
             completion(.error(error: .paymentNotAllowed))
             completion(.error(error: .paymentNotAllowed))
             return
             return
         }
         }
-
-        inflightPurchases[product.productIdentifier] = InAppProductPurchaseRequest.startPayment(product: product, atomically: atomically, applicationUsername: applicationUsername) { results in
-
-            self.inflightPurchases[product.productIdentifier] = nil
+        
+        paymentQueueController.startPayment(Payment(product: product, atomically: atomically, applicationUsername: applicationUsername) { result in
             
             
-            if let purchasedProductTransaction = results.first {
-                let returnValue = self.processPurchaseResult(purchasedProductTransaction)
-                completion(returnValue)
-            }
-        }
+            completion(self.processPurchaseResult(result))
+        })
     }
     }
 
 
-    private func processPurchaseResult(_ result: InAppProductPurchaseRequest.TransactionResult) -> PurchaseResult {
+    private func processPurchaseResult(_ result: TransactionResult) -> PurchaseResult {
         switch result {
         switch result {
         case .purchased(let product):
         case .purchased(let product):
             return .success(product: product)
             return .success(product: product)
@@ -230,7 +132,7 @@ public class SwiftyStoreKit {
         }
         }
     }
     }
     
     
-    private func processRestoreResults(_ results: [InAppProductPurchaseRequest.TransactionResult]) -> RestoreResults {
+    private func processRestoreResults(_ results: [TransactionResult]) -> RestoreResults {
         var restoredProducts: [Product] = []
         var restoredProducts: [Product] = []
         var restoreFailedProducts: [(Swift.Error, String?)] = []
         var restoreFailedProducts: [(Swift.Error, String?)] = []
         for result in results {
         for result in results {
@@ -246,19 +148,118 @@ public class SwiftyStoreKit {
         return RestoreResults(restoredProducts: restoredProducts, restoreFailedProducts: restoreFailedProducts)
         return RestoreResults(restoredProducts: restoredProducts, restoreFailedProducts: restoreFailedProducts)
     }
     }
     
     
-    private func requestProducts(_ productIds: Set<String>, completion: @escaping (RetrieveResults) -> ()) {
+    private func storeInternalError(code: Int = 0, description: String = "") -> NSError {
+        return NSError(domain: "SwiftyStoreKit", code: code, userInfo: [ NSLocalizedDescriptionKey: description ])
+    }
+}
+
+extension SwiftyStoreKit {
+
+    // MARK: Singleton
+    private static let sharedInstance = SwiftyStoreKit()
+    
+    // MARK: Public methods - Purchases
+    public class var canMakePayments: Bool {
+        return SKPaymentQueue.canMakePayments()
+    }
+
+    public class func retrieveProductsInfo(_ productIds: Set<String>, completion: @escaping (RetrieveResults) -> ()) {
         
         
-        inflightQueries[productIds] = InAppProductQueryRequest.startQuery(productIds) { result in
+        return sharedInstance.retrieveProductsInfo(productIds, completion: completion)
+    }
+    
+    /**
+     *  Purchase a product
+     *  - Parameter productId: productId as specified in iTunes Connect
+     *  - 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 purchaseProduct(_ productId: String, atomically: Bool = true, applicationUsername: String = "", completion: @escaping ( PurchaseResult) -> ()) {
+        
+        sharedInstance.purchaseProduct(productId, atomically: atomically, applicationUsername: applicationUsername, completion: completion)
+    }
+    
+    public class func restorePurchases(atomically: Bool = true, applicationUsername: String = "", completion: @escaping (RestoreResults) -> ()) {
+        
+        sharedInstance.restorePurchases(atomically: atomically, applicationUsername: applicationUsername, completion: completion)
+    }
+    
+    public class func completeTransactions(atomically: Bool = true, completion: @escaping ([Product]) -> ()) {
         
         
-            self.inflightQueries[productIds] = nil
-            for product in result.retrievedProducts {
-                self.store.addProduct(product)
+        sharedInstance.completeTransactions(atomically: atomically, completion: completion)
+    }
+    
+    
+    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) -> ()) {
+        
+        sharedInstance.refreshReceipt(receiptProperties, completion: completion)
+    }
+}
+
+extension SwiftyStoreKit {
+ 
+    // MARK: Public methods - Receipt verification
+    
+    /**
+     * Return receipt data from the application bundle. This is read from Bundle.main.appStoreReceiptURL
+     */
+    public static var localReceiptData: Data? {
+        return InAppReceipt.appStoreReceiptData
+    }
+    
+    /**
+     *  Verify application receipt
+     *  - 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) -> ()) {
+        
+        InAppReceipt.verify(using: validator, password: password) { result in
+            
+            DispatchQueue.main.async {
+                completion(result)
             }
             }
-            completion(result)
         }
         }
     }
     }
     
     
-    private func storeInternalError(code: Int = 0, description: String = "") -> NSError {
-        return NSError(domain: "SwiftyStoreKit", code: code, userInfo: [ NSLocalizedDescriptionKey: description ])
+    /**
+     *  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 {
+        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 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
+     */
+    public class func verifySubscription(
+        productId: String,
+        inReceipt receipt: ReceiptInfo,
+        validUntil date: Date = Date(),
+        validDuration duration: TimeInterval? = nil
+        ) -> VerifySubscriptionResult {
+        return InAppReceipt.verifySubscription(productId: productId, inReceipt: receipt, validUntil: date, validDuration: duration)
     }
     }
 }
 }

+ 107 - 0
SwiftyStoreKitTests/CompleteTransactionsControllerTests.swift

@@ -0,0 +1,107 @@
+//
+// CompleteTransactionsControllerTests.swift
+// SwiftyStoreKit
+//
+// 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
+import StoreKit
+@testable import SwiftyStoreKit
+
+class CompleteTransactionsControllerTests: XCTestCase {
+
+    func testProcessTransactions_when_oneRestoredTransaction_then_finishesTransaction_callsCallback_noRemainingTransactions() {
+        
+        let productIdentifier = "com.SwiftyStoreKit.product1"
+        let testProduct = TestProduct(productIdentifier: productIdentifier)
+        
+        let transaction = TestPaymentTransaction(payment: SKPayment(product: testProduct), transactionState: .restored)
+        
+        var callbackCalled = false
+        let restorePurchases = CompleteTransactions(atomically: true) { products in
+            callbackCalled = true
+            XCTAssertEqual(products.count, 1)
+            let product = products.first!
+            XCTAssertEqual(product.productId, productIdentifier)
+        }
+        
+        let completeTransactionsController = makeCompleteTransactionsController(completeTransactions: restorePurchases)
+        
+        let spy = PaymentQueueSpy()
+        
+        let remainingTransactions = completeTransactionsController.processTransactions([transaction], on: spy)
+        
+        XCTAssertEqual(remainingTransactions.count, 0)
+        
+        XCTAssertTrue(callbackCalled)
+        
+        XCTAssertEqual(spy.finishTransactionCalledCount, 1)
+    }
+    
+    func testProcessTransactions_when_oneTransactionForEachState_then_finishesTransactions_callsCallback_onePurchasingTransactionRemaining() {
+        
+        let transactions = [
+            makeTestPaymentTransaction(productIdentifier: "com.SwiftyStoreKit.product1", transactionState: .purchased),
+            makeTestPaymentTransaction(productIdentifier: "com.SwiftyStoreKit.product2", transactionState: .failed),
+            makeTestPaymentTransaction(productIdentifier: "com.SwiftyStoreKit.product3", transactionState: .restored),
+            makeTestPaymentTransaction(productIdentifier: "com.SwiftyStoreKit.product4", transactionState: .deferred),
+            makeTestPaymentTransaction(productIdentifier: "com.SwiftyStoreKit.product5", transactionState: .purchasing),
+        ]
+        
+        var callbackCalled = false
+        let restorePurchases = CompleteTransactions(atomically: true) { products in
+            callbackCalled = true
+            XCTAssertEqual(products.count, 4)
+
+            for i in 0..<4 {
+                XCTAssertEqual(products[i].productId, transactions[i].payment.productIdentifier)
+            }
+        }
+        
+        let completeTransactionsController = makeCompleteTransactionsController(completeTransactions: restorePurchases)
+        
+        let spy = PaymentQueueSpy()
+        
+        let remainingTransactions = completeTransactionsController.processTransactions(transactions, on: spy)
+        
+        XCTAssertEqual(remainingTransactions.count, 1)
+        
+        XCTAssertTrue(callbackCalled)
+        
+        XCTAssertEqual(spy.finishTransactionCalledCount, 4)
+    }
+    
+    func makeTestPaymentTransaction(productIdentifier: String, transactionState: SKPaymentTransactionState) -> TestPaymentTransaction {
+        
+        let testProduct = TestProduct(productIdentifier: productIdentifier)
+        return TestPaymentTransaction(payment: SKPayment(product: testProduct), transactionState: transactionState)
+    }
+    
+    func makeCompleteTransactionsController(completeTransactions: CompleteTransactions?) -> CompleteTransactionsController {
+        
+        let completeTransactionsController = CompleteTransactionsController()
+        
+        completeTransactionsController.completeTransactions = completeTransactions
+        
+        return completeTransactionsController
+    }
+
+}

+ 22 - 0
SwiftyStoreKitTests/Info.plist

@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>CFBundleDevelopmentRegion</key>
+	<string>en</string>
+	<key>CFBundleExecutable</key>
+	<string>$(EXECUTABLE_NAME)</string>
+	<key>CFBundleIdentifier</key>
+	<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
+	<key>CFBundleInfoDictionaryVersion</key>
+	<string>6.0</string>
+	<key>CFBundleName</key>
+	<string>$(PRODUCT_NAME)</string>
+	<key>CFBundlePackageType</key>
+	<string>BNDL</string>
+	<key>CFBundleShortVersionString</key>
+	<string>1.0</string>
+	<key>CFBundleVersion</key>
+	<string>1</string>
+</dict>
+</plist>

+ 268 - 0
SwiftyStoreKitTests/PaymentQueueControllerTests.swift

@@ -0,0 +1,268 @@
+//
+// PaymentQueueControllerTests.swift
+// SwiftyStoreKit
+//
+// 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
+import StoreKit
+@testable import SwiftyStoreKit
+
+extension Payment {
+    init(product: SKProduct, atomically: Bool, applicationUsername: String, callback: @escaping (TransactionResult) -> ()) {
+        self.product = product
+        self.atomically = atomically
+        self.applicationUsername = applicationUsername
+        self.callback = callback
+    }
+}
+
+class PaymentQueueControllerTests: XCTestCase {
+
+    // MARK: init/deinit
+    func testInit_registersAsObserver() {
+        
+        let spy = PaymentQueueSpy()
+        
+        let paymentQueueController = PaymentQueueController(paymentQueue: spy)
+        
+        XCTAssertTrue(spy.observer === paymentQueueController)
+    }
+    
+    func testDeinit_removesObserver() {
+        
+        let spy = PaymentQueueSpy()
+        
+        let _ = PaymentQueueController(paymentQueue: spy)
+        
+        XCTAssertNil(spy.observer)
+    }
+    
+    // MARK: Start payment
+    
+    func testStartTransaction_QueuesOnePayment() {
+        
+        let spy = PaymentQueueSpy()
+        
+        let paymentQueueController = PaymentQueueController(paymentQueue: spy)
+
+        let payment = makeTestPayment(productIdentifier: "com.SwiftyStoreKit.product1") { result in }
+
+        paymentQueueController.startPayment(payment)
+        
+        XCTAssertEqual(spy.payments.count, 1)
+    }
+    
+    // MARK: SKPaymentTransactionObserver callbacks
+    func testPaymentQueue_when_oneTransactionForEachState_onePayment_oneRestorePurchases_oneCompleteTransactions_then_correctCallbacksCalled() {
+
+        // setup
+        let spy = PaymentQueueSpy()
+        
+        let paymentQueueController = PaymentQueueController(paymentQueue: spy)
+
+        let purchasedProductIdentifier = "com.SwiftyStoreKit.product1"
+        let failedProductIdentifier = "com.SwiftyStoreKit.product2"
+        let restoredProductIdentifier = "com.SwiftyStoreKit.product3"
+        let deferredProductIdentifier = "com.SwiftyStoreKit.product4"
+        let purchasingProductIdentifier = "com.SwiftyStoreKit.product5"
+        
+        let transactions = [
+            makeTestPaymentTransaction(productIdentifier: purchasedProductIdentifier, transactionState: .purchased),
+            makeTestPaymentTransaction(productIdentifier: failedProductIdentifier, transactionState: .failed),
+            makeTestPaymentTransaction(productIdentifier: restoredProductIdentifier, transactionState: .restored),
+            makeTestPaymentTransaction(productIdentifier: deferredProductIdentifier, transactionState: .deferred),
+            makeTestPaymentTransaction(productIdentifier: purchasingProductIdentifier, transactionState: .purchasing),
+            ]
+
+        
+        var paymentCallbackCalled = false
+        let testPayment = makeTestPayment(productIdentifier: purchasedProductIdentifier) { result in
+            paymentCallbackCalled = true
+            if case .purchased(let product) = result {
+                XCTAssertEqual(product.productId, purchasedProductIdentifier)
+            }
+            else {
+                XCTFail("expected purchased callback with product id")
+            }
+        }
+
+        var restorePurchasesCallbackCalled = false
+        let restorePurchases = RestorePurchases(atomically: true) { results in
+            restorePurchasesCallbackCalled = true
+            XCTAssertEqual(results.count, 1)
+            let first = results.first!
+            if case .restored(let restoredProduct) = first {
+                XCTAssertEqual(restoredProduct.productId, restoredProductIdentifier)
+            }
+            else {
+                XCTFail("expected restored callback with product")
+            }
+        }
+        
+        var completeTransactionsCallbackCalled = false
+        let completeTransactions = CompleteTransactions(atomically: true) { products in
+            completeTransactionsCallbackCalled = true
+            XCTAssertEqual(products.count, 2)
+            XCTAssertEqual(products[0].productId, failedProductIdentifier)
+            XCTAssertEqual(products[1].productId, deferredProductIdentifier)
+        }
+        
+        // run
+        paymentQueueController.startPayment(testPayment)
+        
+        paymentQueueController.restorePurchases(restorePurchases)
+
+        paymentQueueController.completeTransactions(completeTransactions)
+        
+        paymentQueueController.paymentQueue(SKPaymentQueue(), updatedTransactions: transactions)
+        paymentQueueController.paymentQueueRestoreCompletedTransactionsFinished(SKPaymentQueue())
+        
+        // verify
+        XCTAssertTrue(paymentCallbackCalled)
+        XCTAssertTrue(restorePurchasesCallbackCalled)
+        XCTAssertTrue(completeTransactionsCallbackCalled)
+    }
+    
+    func testPaymentQueue_when_oneTransactionForEachState_onePayment_noRestorePurchases_oneCompleteTransactions_then_correctCallbacksCalled() {
+        
+        // setup
+        let spy = PaymentQueueSpy()
+        
+        let paymentQueueController = PaymentQueueController(paymentQueue: spy)
+        
+        let purchasedProductIdentifier = "com.SwiftyStoreKit.product1"
+        let failedProductIdentifier = "com.SwiftyStoreKit.product2"
+        let restoredProductIdentifier = "com.SwiftyStoreKit.product3"
+        let deferredProductIdentifier = "com.SwiftyStoreKit.product4"
+        let purchasingProductIdentifier = "com.SwiftyStoreKit.product5"
+        
+        let transactions = [
+            makeTestPaymentTransaction(productIdentifier: purchasedProductIdentifier, transactionState: .purchased),
+            makeTestPaymentTransaction(productIdentifier: failedProductIdentifier, transactionState: .failed),
+            makeTestPaymentTransaction(productIdentifier: restoredProductIdentifier, transactionState: .restored),
+            makeTestPaymentTransaction(productIdentifier: deferredProductIdentifier, transactionState: .deferred),
+            makeTestPaymentTransaction(productIdentifier: purchasingProductIdentifier, transactionState: .purchasing),
+            ]
+        
+        
+        var paymentCallbackCalled = false
+        let testPayment = makeTestPayment(productIdentifier: purchasedProductIdentifier) { result in
+            paymentCallbackCalled = true
+            if case .purchased(let product) = result {
+                XCTAssertEqual(product.productId, purchasedProductIdentifier)
+            }
+            else {
+                XCTFail("expected purchased callback with product id")
+            }
+        }
+        
+        var completeTransactionsCallbackCalled = false
+        let completeTransactions = CompleteTransactions(atomically: true) { products in
+            completeTransactionsCallbackCalled = true
+            XCTAssertEqual(products.count, 3)
+            XCTAssertEqual(products[0].productId, failedProductIdentifier)
+            XCTAssertEqual(products[1].productId, restoredProductIdentifier)
+            XCTAssertEqual(products[2].productId, deferredProductIdentifier)
+        }
+        
+        // run
+        paymentQueueController.startPayment(testPayment)
+        
+        paymentQueueController.completeTransactions(completeTransactions)
+        
+        paymentQueueController.paymentQueue(SKPaymentQueue(), updatedTransactions: transactions)
+        paymentQueueController.paymentQueueRestoreCompletedTransactionsFinished(SKPaymentQueue())
+        
+        // verify
+        XCTAssertTrue(paymentCallbackCalled)
+        XCTAssertTrue(completeTransactionsCallbackCalled)
+    }
+
+    func testPaymentQueue_when_oneTransactionForEachState_noPayments_oneRestorePurchases_oneCompleteTransactions_then_correctCallbacksCalled() {
+        
+        // setup
+        let spy = PaymentQueueSpy()
+        
+        let paymentQueueController = PaymentQueueController(paymentQueue: spy)
+        
+        let purchasedProductIdentifier = "com.SwiftyStoreKit.product1"
+        let failedProductIdentifier = "com.SwiftyStoreKit.product2"
+        let restoredProductIdentifier = "com.SwiftyStoreKit.product3"
+        let deferredProductIdentifier = "com.SwiftyStoreKit.product4"
+        let purchasingProductIdentifier = "com.SwiftyStoreKit.product5"
+        
+        let transactions = [
+            makeTestPaymentTransaction(productIdentifier: purchasedProductIdentifier, transactionState: .purchased),
+            makeTestPaymentTransaction(productIdentifier: failedProductIdentifier, transactionState: .failed),
+            makeTestPaymentTransaction(productIdentifier: restoredProductIdentifier, transactionState: .restored),
+            makeTestPaymentTransaction(productIdentifier: deferredProductIdentifier, transactionState: .deferred),
+            makeTestPaymentTransaction(productIdentifier: purchasingProductIdentifier, transactionState: .purchasing),
+            ]
+        
+        var restorePurchasesCallbackCalled = false
+        let restorePurchases = RestorePurchases(atomically: true) { results in
+            restorePurchasesCallbackCalled = true
+            XCTAssertEqual(results.count, 1)
+            let first = results.first!
+            if case .restored(let restoredProduct) = first {
+                XCTAssertEqual(restoredProduct.productId, restoredProductIdentifier)
+            }
+            else {
+                XCTFail("expected restored callback with product")
+            }
+        }
+        
+        var completeTransactionsCallbackCalled = false
+        let completeTransactions = CompleteTransactions(atomically: true) { products in
+            completeTransactionsCallbackCalled = true
+            XCTAssertEqual(products.count, 3)
+            XCTAssertEqual(products[0].productId, purchasedProductIdentifier)
+            XCTAssertEqual(products[1].productId, failedProductIdentifier)
+            XCTAssertEqual(products[2].productId, deferredProductIdentifier)
+        }
+        
+        // run
+        paymentQueueController.restorePurchases(restorePurchases)
+        
+        paymentQueueController.completeTransactions(completeTransactions)
+        
+        paymentQueueController.paymentQueue(SKPaymentQueue(), updatedTransactions: transactions)
+        paymentQueueController.paymentQueueRestoreCompletedTransactionsFinished(SKPaymentQueue())
+        
+        // verify
+        XCTAssertTrue(restorePurchasesCallbackCalled)
+        XCTAssertTrue(completeTransactionsCallbackCalled)
+    }
+    
+    // MARK: Helpers
+    func makeTestPaymentTransaction(productIdentifier: String, transactionState: SKPaymentTransactionState) -> TestPaymentTransaction {
+        
+        let testProduct = TestProduct(productIdentifier: productIdentifier)
+        return TestPaymentTransaction(payment: SKPayment(product: testProduct), transactionState: transactionState)
+    }
+    
+    func makeTestPayment(productIdentifier: String, atomically: Bool = true, callback: @escaping (TransactionResult) -> ()) -> Payment {
+        
+        let testProduct = TestProduct(productIdentifier: productIdentifier)
+        return Payment(product: testProduct, atomically: atomically, applicationUsername: "", callback: callback)
+    }
+}

+ 47 - 0
SwiftyStoreKitTests/PaymentQueueSpy.swift

@@ -0,0 +1,47 @@
+//
+//  PaymentQueueSpy.swift
+//  SwiftyStoreKit
+//
+//  Created by Andrea Bizzotto on 17/01/2017.
+//  Copyright © 2017 musevisions. All rights reserved.
+//
+
+import SwiftyStoreKit
+import StoreKit
+
+class PaymentQueueSpy: PaymentQueue {
+    
+    weak var observer: SKPaymentTransactionObserver?
+    
+    var payments: [SKPayment] = []
+    
+    var restoreCompletedTransactionCalledCount = 0
+    
+    var finishTransactionCalledCount = 0
+
+    func add(_ observer: SKPaymentTransactionObserver) {
+        
+        self.observer = observer
+    }
+    func remove(_ observer: SKPaymentTransactionObserver) {
+        
+        if self.observer === observer {
+            self.observer = nil
+        }
+    }
+    
+    func add(_ payment: SKPayment) {
+        
+        payments.append(payment)
+    }
+    
+    func restoreCompletedTransactions(withApplicationUsername username: String?) {
+        
+        restoreCompletedTransactionCalledCount += 1
+    }
+
+    func finishTransaction(_ transaction: SKPaymentTransaction) {
+        
+        finishTransactionCalledCount += 1
+    }
+}

+ 13 - 0
SwiftyStoreKitTests/PaymentTransactionObserverFake.swift

@@ -0,0 +1,13 @@
+//
+//  PaymentTransactionObserverFake.swift
+//  SwiftyStoreKit
+//
+//  Created by Andrea Bizzotto on 17/01/2017.
+//  Copyright © 2017 musevisions. All rights reserved.
+//
+
+import UIKit
+
+class PaymentTransactionObserverFake: NSObject {
+
+}

+ 220 - 0
SwiftyStoreKitTests/PaymentsControllerTests.swift

@@ -0,0 +1,220 @@
+//
+// PaymentsControllerTests.swift
+// SwiftyStoreKit
+//
+// 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
+import StoreKit
+@testable import SwiftyStoreKit
+
+class PaymentsControllerTests: XCTestCase {
+
+    func testInsertPayment_hasPayment() {
+     
+        let payment = makeTestPayment(productIdentifier: "com.SwiftyStoreKit.product1") { result in }
+
+        let paymentsController = makePaymentsController(appendPayments: [payment])
+        
+        XCTAssertTrue(paymentsController.hasPayment(payment))
+    }
+    
+    func testProcessTransaction_when_onePayment_transactionStatePurchased_then_removesPayment_finishesTransaction_callsCallback() {
+        
+        let productIdentifier = "com.SwiftyStoreKit.product1"
+        let testProduct = TestProduct(productIdentifier: productIdentifier)
+        
+        var callbackCalled = false
+        let payment = makeTestPayment(product: testProduct) { result in
+            
+            callbackCalled = true
+            if case .purchased(let product) = result {
+                XCTAssertEqual(product.productId, productIdentifier)
+            }
+            else {
+                XCTFail("expected purchased callback with product id")
+            }
+        }
+        
+        let paymentsController = makePaymentsController(appendPayments: [payment])
+        
+        let transaction = TestPaymentTransaction(payment: SKPayment(product: testProduct), transactionState: .purchased)
+        
+        let spy = PaymentQueueSpy()
+        
+        let remainingTransactions = paymentsController.processTransactions([transaction], on: spy)
+        
+        XCTAssertEqual(remainingTransactions.count, 0)
+
+        XCTAssertFalse(paymentsController.hasPayment(payment))
+        
+        XCTAssertTrue(callbackCalled)
+        
+        XCTAssertEqual(spy.finishTransactionCalledCount, 1)
+    }
+    
+    func testProcessTransaction_when_onePayment_transactionStateFailed_then_removesPayment_finishesTransaction_callsCallback() {
+        
+        let productIdentifier = "com.SwiftyStoreKit.product1"
+        let testProduct = TestProduct(productIdentifier: productIdentifier)
+        
+        var callbackCalled = false
+        let payment = makeTestPayment(product: testProduct) { result in
+            
+            callbackCalled = true
+            if case .failed(_) = result {
+                
+            }
+            else {
+                XCTFail("expected failed callback with error")
+            }
+        }
+        
+        let paymentsController = makePaymentsController(appendPayments: [payment])
+        
+        let transaction = TestPaymentTransaction(payment: SKPayment(product: testProduct), transactionState: .failed)
+        
+        let spy = PaymentQueueSpy()
+        
+        let remainingTransactions = paymentsController.processTransactions([transaction], on: spy)
+        
+        XCTAssertEqual(remainingTransactions.count, 0)
+        
+        XCTAssertFalse(paymentsController.hasPayment(payment))
+        
+        XCTAssertTrue(callbackCalled)
+        
+        XCTAssertEqual(spy.finishTransactionCalledCount, 1)
+    }
+    
+    func testProcessTransaction_when_twoPaymentsSameId_firstTransactionStatePurchased_secondTransactionStateFailed_then_removesPayments_finishesTransactions_callsCallbacks() {
+
+        let productIdentifier = "com.SwiftyStoreKit.product1"
+        let testProduct1 = TestProduct(productIdentifier: productIdentifier)
+        
+        var callback1Called = false
+        let payment1 = makeTestPayment(product: testProduct1) { result in
+            
+            callback1Called = true
+            if case .purchased(let product) = result {
+                XCTAssertEqual(product.productId, productIdentifier)
+            }
+            else {
+                XCTFail("expected purchased callback with product id")
+            }
+        }
+
+        let testProduct2 = TestProduct(productIdentifier: productIdentifier)
+
+        var callback2Called = false
+        let payment2 = makeTestPayment(product: testProduct2) { result in
+            callback2Called = true
+            if case .failed(_) = result {
+                
+            }
+            else {
+                XCTFail("expected failed callback with error")
+            }
+        }
+
+        let paymentsController = makePaymentsController(appendPayments: [payment1, payment2])
+        
+        let transaction1 = TestPaymentTransaction(payment: SKPayment(product: testProduct1), transactionState: .purchased)
+        let transaction2 = TestPaymentTransaction(payment: SKPayment(product: testProduct2), transactionState: .failed)
+        
+        let spy = PaymentQueueSpy()
+        
+        let remainingTransactions = paymentsController.processTransactions([transaction1, transaction2], on: spy)
+        
+        XCTAssertEqual(remainingTransactions.count, 0)
+        
+        XCTAssertFalse(paymentsController.hasPayment(payment1))
+        XCTAssertFalse(paymentsController.hasPayment(payment2))
+        
+        XCTAssertTrue(callback1Called)
+        XCTAssertTrue(callback2Called)
+        
+        XCTAssertEqual(spy.finishTransactionCalledCount, 2)
+    }
+
+    func testProcessTransaction_when_twoPaymentsSameId_firstPayment_transactionStatePurchased_then_removesFirstPayment_finishesTransaction_callsCallback() {
+        
+        let productIdentifier = "com.SwiftyStoreKit.product1"
+        let testProduct1 = TestProduct(productIdentifier: productIdentifier)
+        
+        var callback1Called = false
+        let payment1 = makeTestPayment(product: testProduct1) { result in
+            
+            callback1Called = true
+            if case .purchased(let product) = result {
+                XCTAssertEqual(product.productId, productIdentifier)
+            }
+            else {
+                XCTFail("expected purchased callback with product id")
+            }
+        }
+        
+        let testProduct2 = TestProduct(productIdentifier: productIdentifier)
+        let payment2 = makeTestPayment(product: testProduct2) { result in
+            
+            XCTFail("unexpected callback for second payment")
+        }
+        
+        let paymentsController = makePaymentsController(appendPayments: [payment1, payment2])
+        
+        let transaction1 = TestPaymentTransaction(payment: SKPayment(product: testProduct1), transactionState: .purchased)
+        
+        let spy = PaymentQueueSpy()
+        
+        let remainingTransactions = paymentsController.processTransactions([transaction1], on: spy)
+        
+        XCTAssertEqual(remainingTransactions.count, 0)
+        
+        // First one removed, but second one with same identifier still there
+        XCTAssertTrue(paymentsController.hasPayment(payment2))
+        
+        XCTAssertTrue(callback1Called)
+        
+        XCTAssertEqual(spy.finishTransactionCalledCount, 1)
+    }
+
+    
+    func makePaymentsController(appendPayments payments: [Payment]) -> PaymentsController {
+        
+        let paymentsController = PaymentsController()
+        
+        payments.forEach { paymentsController.append($0) }
+        
+        return paymentsController
+    }
+    
+    func makeTestPayment(product: SKProduct, atomically: Bool = true, callback: @escaping (TransactionResult) -> ()) -> Payment {
+        
+        return Payment(product: product, atomically: atomically, applicationUsername: "", callback: callback)
+    }
+    
+    func makeTestPayment(productIdentifier: String, atomically: Bool = true, callback: @escaping (TransactionResult) -> ()) -> Payment {
+
+        let product = TestProduct(productIdentifier: productIdentifier)
+        return makeTestPayment(product: product, atomically: atomically, callback: callback)
+
+    }
+}

+ 168 - 0
SwiftyStoreKitTests/RestorePurchasesControllerTests.swift

@@ -0,0 +1,168 @@
+//
+// RestorePurchasesControllerTests.swift
+// SwiftyStoreKit
+//
+// 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
+import StoreKit
+@testable import SwiftyStoreKit
+
+class RestorePurchasesControllerTests: XCTestCase {
+    
+    func testProcessTransactions_when_oneRestoredTransaction_then_finishesTransaction_callsCallback_noRemainingTransactions() {
+        
+        let productIdentifier = "com.SwiftyStoreKit.product1"
+        let testProduct = TestProduct(productIdentifier: productIdentifier)
+        
+        let transaction = TestPaymentTransaction(payment: SKPayment(product: testProduct), transactionState: .restored)
+
+        var callbackCalled = false
+        let restorePurchases = RestorePurchases(atomically: true) { results in
+            callbackCalled = true
+            XCTAssertEqual(results.count, 1)
+            let restored = results.first!
+            if case .restored(let restoredProduct) = restored {
+                XCTAssertEqual(restoredProduct.productId, productIdentifier)
+            }
+            else {
+                XCTFail("expected restored callback with product")
+            }
+        }
+        
+        let restorePurchasesController = makeRestorePurchasesController(restorePurchases: restorePurchases)
+        
+        let spy = PaymentQueueSpy()
+
+        let remainingTransactions = restorePurchasesController.processTransactions([transaction], on: spy)
+        restorePurchasesController.restoreCompletedTransactionsFinished()
+        
+        XCTAssertEqual(remainingTransactions.count, 0)
+
+        XCTAssertTrue(callbackCalled)
+
+        XCTAssertEqual(spy.finishTransactionCalledCount, 1)
+    }
+
+    func testProcessTransactions_when_twoRestoredTransactions_oneFailedTransaction_onePurchasedTransaction_then_finishesTwoTransactions_callsCallback_twoRemainingTransaction() {
+        
+        let productIdentifier1 = "com.SwiftyStoreKit.product1"
+        let testProduct1 = TestProduct(productIdentifier: productIdentifier1)
+        let transaction1 = TestPaymentTransaction(payment: SKPayment(product: testProduct1), transactionState: .restored)
+
+        let productIdentifier2 = "com.SwiftyStoreKit.product2"
+        let testProduct2 = TestProduct(productIdentifier: productIdentifier2)
+        let transaction2 = TestPaymentTransaction(payment: SKPayment(product: testProduct2), transactionState: .restored)
+
+        let productIdentifier3 = "com.SwiftyStoreKit.product3"
+        let testProduct3 = TestProduct(productIdentifier: productIdentifier3)
+        let transaction3 = TestPaymentTransaction(payment: SKPayment(product: testProduct3), transactionState: .failed)
+
+        let productIdentifier4 = "com.SwiftyStoreKit.product4"
+        let testProduct4 = TestProduct(productIdentifier: productIdentifier4)
+        let transaction4 = TestPaymentTransaction(payment: SKPayment(product: testProduct4), transactionState: .purchased)
+
+        let transactions = [transaction1, transaction2, transaction3, transaction4]
+
+        var callbackCalled = false
+        let restorePurchases = RestorePurchases(atomically: true) { results in
+            callbackCalled = true
+            XCTAssertEqual(results.count, 2)
+            let first = results.first!
+            if case .restored(let restoredProduct) = first {
+                XCTAssertEqual(restoredProduct.productId, productIdentifier1)
+            }
+            else {
+                XCTFail("expected restored callback with product")
+            }
+            let last = results.last!
+            if case .restored(let restoredProduct) = last {
+                XCTAssertEqual(restoredProduct.productId, productIdentifier2)
+            }
+            else {
+                XCTFail("expected restored callback with product")
+            }
+        }
+        
+        let restorePurchasesController = makeRestorePurchasesController(restorePurchases: restorePurchases)
+        
+        let spy = PaymentQueueSpy()
+        
+        let remainingTransactions = restorePurchasesController.processTransactions(transactions, on: spy)
+        restorePurchasesController.restoreCompletedTransactionsFinished()
+
+        XCTAssertEqual(remainingTransactions.count, 2)
+        
+        XCTAssertTrue(callbackCalled)
+        
+        XCTAssertEqual(spy.finishTransactionCalledCount, 2)
+    }
+    
+    func testRestoreCompletedTransactionsFailed_callsCallbackWithError() {
+
+        var callbackCalled = false
+        let restorePurchases = RestorePurchases(atomically: true) { results in
+            callbackCalled = true
+
+            XCTAssertEqual(results.count, 1)
+            let first = results.first!
+            if case .failed(_) = first {
+                
+            }
+            else {
+                XCTFail("expected failed callback with error")
+            }
+        }
+        
+        let restorePurchasesController = makeRestorePurchasesController(restorePurchases: restorePurchases)
+
+        let error = NSError(domain: "SwiftyStoreKit", code: 0, userInfo: nil)
+        
+        restorePurchasesController.restoreCompletedTransactionsFailed(withError: error)
+
+        XCTAssertTrue(callbackCalled)
+    }
+
+    func testRestoreCompletedTransactionsFinished_callsCallbackWithNoTransactions() {
+        
+        var callbackCalled = false
+        let restorePurchases = RestorePurchases(atomically: true) { results in
+            callbackCalled = true
+
+            XCTAssertEqual(results.count, 0)
+        }
+        let restorePurchasesController = makeRestorePurchasesController(restorePurchases: restorePurchases)
+        
+        restorePurchasesController.restoreCompletedTransactionsFinished()
+        
+        XCTAssertTrue(callbackCalled)
+    }
+    
+    func makeRestorePurchasesController(restorePurchases: RestorePurchases?) -> RestorePurchasesController {
+        
+        let restorePurchasesController = RestorePurchasesController()
+        
+        restorePurchasesController.restorePurchases = restorePurchases
+        
+        return restorePurchasesController
+    }
+}

+ 44 - 0
SwiftyStoreKitTests/TestPaymentTransaction.swift

@@ -0,0 +1,44 @@
+//
+// TestPaymentTransaction.swift
+// SwiftyStoreKit
+//
+// 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 StoreKit
+
+class TestPaymentTransaction: SKPaymentTransaction {
+
+    let _transactionState: SKPaymentTransactionState
+    let _payment: SKPayment
+    
+    init(payment: SKPayment, transactionState: SKPaymentTransactionState) {
+        _transactionState = transactionState
+        _payment = payment
+    }
+
+    override var payment: SKPayment {
+        return _payment
+    }
+    
+    override var transactionState: SKPaymentTransactionState {
+        return _transactionState
+    }
+}

+ 39 - 0
SwiftyStoreKitTests/TestProduct.swift

@@ -0,0 +1,39 @@
+//
+// TestProduct.swift
+// SwiftyStoreKit
+//
+// 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 StoreKit
+
+class TestProduct: SKProduct {
+    
+    var _productIdentifier: String = ""
+    
+    override var productIdentifier: String {
+        return _productIdentifier
+    }
+    
+    init(productIdentifier: String) {
+        _productIdentifier = productIdentifier
+        super.init()
+    }
+}

+ 2 - 0
scripts/build.sh

@@ -3,3 +3,5 @@
 xcodebuild -project SwiftyStoreKit.xcodeproj -target SwiftyStoreKit_iOS
 xcodebuild -project SwiftyStoreKit.xcodeproj -target SwiftyStoreKit_iOS
 xcodebuild -project SwiftyStoreKit.xcodeproj -target SwiftyStoreKit_macOS
 xcodebuild -project SwiftyStoreKit.xcodeproj -target SwiftyStoreKit_macOS
 xcodebuild -project SwiftyStoreKit.xcodeproj -target SwiftyStoreKit_tvOS
 xcodebuild -project SwiftyStoreKit.xcodeproj -target SwiftyStoreKit_tvOS
+
+xcodebuild test -project SwiftyStoreKit.xcodeproj -scheme SwiftyStoreKitTests -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 6,OS=9.3'