It's a little hard to debug what you're doing as you're updating UserDefaults in two places and your purchase tracking code is tightly coupled to your purchasing code.
I would separate the concerns of purchasing and tracking purchases so you only have to keep track of and update or unlock them in one place. Something like this...
First off I'd separate all the iTunesConnect purchasing code into separate discreet classes (one for the iTunesStore, and one for the iTunesStore callback observer), create models to represent the purchase states and error states and create callbacks to notify the app of significant actions that happen during the product validation and purchase flow.
The callback protocols would look something like this:
import StoreKit
/// Defines callbacks that will occur when products are being validated with the iTunes Store.
protocol iTunesProductStatusReceiver: class {
func didValidateProducts(_ products: [SKProduct])
func didReceiveInvalidProductIdentifiers(_ identifiers: [String])
}
/// Defines callbacks that occur during the purchase or restore process
protocol iTunesPurchaseStatusReceiver: class {
func purchaseStatusDidUpdate(_ status: PurchaseStatus)
func restoreStatusDidUpdate(_ status: PurchaseStatus)
}
My iTunesStore class would look like this, and it would handle all interactions with iTunesConnect (or AppStoreConnect now):
import Foundation
import StoreKit
class iTunesStore: NSObject, SKProductsRequestDelegate {
weak var delegate: (iTunesProductStatusReceiver & iTunesPurchaseStatusReceiver)?
var transactionObserver: IAPObserver = IAPObserver()
var availableProducts: [SKProduct] = []
var invalidProductIDs: [String] = []
deinit {
SKPaymentQueue.default().remove(self.transactionObserver)
}
override init() {
super.init()
transactionObserver.delegate = self
}
func fetchStoreProducts(identifiers:Set<String>) {
print("Sending products request to ITC")
let request:SKProductsRequest = SKProductsRequest.init(productIdentifiers: identifiers)
request.delegate = self
request.start()
}
func purchaseProduct(identifier:String) {
guard let product = self.product(identifier: identifier) else {
print("No products found with identifier: (identifier)")
// fire purchase status: failed notification
delegate?.purchaseStatusDidUpdate(PurchaseStatus.init(state: .failed, error: PurchaseError.productNotFound, transaction: nil, message:"An error occured"))
return
}
guard SKPaymentQueue.canMakePayments() else {
print("Unable to make purchases...")
delegate?.purchaseStatusDidUpdate(PurchaseStatus.init(state: .failed, error: PurchaseError.unableToPurchase, transaction: nil, message:"An error occured"))
return
}
// Fire purchase began notification
delegate?.purchaseStatusDidUpdate(PurchaseStatus.init(state: .initiated, error: nil, transaction: nil, message:"Processing Purchase"))
let payment = SKPayment.init(product: product)
SKPaymentQueue.default().add(payment)
}
func restorePurchases() {
// Fire purchase began notification
delegate?.restoreStatusDidUpdate(PurchaseStatus.init(state: .initiated, error: nil, transaction: nil, message:"Restoring Purchases"))
SKPaymentQueue.default().restoreCompletedTransactions()
}
// returns a product for a given identifier if it exists in our available products array
func product(identifier:String) -> SKProduct? {
for product in availableProducts {
if product.productIdentifier == identifier {
return product
}
}
return nil
}
}
// Receives purchase status notifications and forwards them to this classes delegate
extension iTunesStore: iTunesPurchaseStatusReceiver {
func purchaseStatusDidUpdate(_ status: PurchaseStatus) {
delegate?.purchaseStatusDidUpdate(status)
}
func restoreStatusDidUpdate(_ status: PurchaseStatus) {
delegate?.restoreStatusDidUpdate(status)
}
}
// MARK: SKProductsRequest Delegate Methods
extension iTunesStore {
@objc(productsRequest:didReceiveResponse:) func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
// set new products
availableProducts = response.products
// set invalid product id's
invalidProductIDs = response.invalidProductIdentifiers
if invalidProductIDs.isEmpty == false {
// call delegate if we received any invalid identifiers
delegate?.didReceiveInvalidProductIdentifiers(invalidProductIDs)
}
print("iTunes Store: Invalid product IDs: (response.invalidProductIdentifiers)")
// call delegate with available products.
delegate?.didValidateProducts(availableProducts)
}
}
You'll notice this class makes use of PurchaseStatus, PurchaseState, and PurchaseError objects to communicate status changes and updates to the app.
These classes look like this:
import Foundation
import StoreKit
enum PurchaseState {
case initiated
case complete
case cancelled
case failed
}
class PurchaseStatus {
var state:PurchaseState
var error:Error?
var transaction:SKPaymentTransaction?
var message:String
init(state:PurchaseState, error:Error?, transaction:SKPaymentTransaction?, message:String) {
self.state = state
self.error = error
self.transaction = transaction
self.message = message
}
}
public enum PurchaseError: Error {
case productNotFound
case unableToPurchase
public var code: Int {
switch self {
case .productNotFound:
return 100101
case .unableToPurchase:
return 100101
}
}
public var description: String {
switch self {
case .productNotFound:
return "No products found for the requested product ID."
case .unableToPurchase:
return "Unable to make purchases. Check to make sure you are signed into a valid itunes account and that you are allowed to make purchases."
}
}
public var title: String {
switch self {
case .productNotFound:
return "Product Not Found"
case .unableToPurchase:
return "Unable to Purchase"
}
}
public var domain: String {
return "com.myAppId.purchaseError"
}
public var recoverySuggestion: String {
switch self {
case .productNotFound:
return "Try again later."
case .unableToPurchase:
return "Check to make sure you are signed into a valid itunes account and that you are allowed to make purchases."
}
}
}
With these classes in place we only have two more pieces to setup our store and make it easily reusable across apps without having to re-write large portions every time we want to add in app purchase in an app.
The next piece is the observer that receives callbacks from StoreKit, the iTunesStore class should be the only class that uses this:
import Foundation
import StoreKit
class IAPObserver: NSObject, SKPaymentTransactionObserver {
// delegate to propagate status update up
weak var delegate: iTunesPurchaseStatusReceiver?
override init() {
super.init()
SKPaymentQueue.default().add(self)
}
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
for transaction in transactions {
switch transaction.transactionState {
case .purchasing: // Transaction is being added to the server queue
break
case .purchased: // Transaction is in queue, user has been charged. Complete transaction now
// Notify purchase complete status
delegate?.purchaseStatusDidUpdate(PurchaseStatus.init(state: .complete, error: nil, transaction: transaction, message:"Purchase Complete."))
SKPaymentQueue.default().finishTransaction(transaction)
case .failed: // Transaction was cancelled or failed before being added to the server queue
// An error occured, notify
delegate?.purchaseStatusDidUpdate(PurchaseStatus.init(state: .failed, error: transaction.error, transaction: transaction, message:"An error occured."))
SKPaymentQueue.default().finishTransaction(transaction)
case .restored: // transaction was rewtored from the users purchase history. Complete transaction now.
// notify purchase completed with status... success
delegate?.restoreStatusDidUpdate(PurchaseStatus.init(state: .complete, error: nil, transaction: transaction, message:"Restore Success!"))
SKPaymentQueue.default().finishTransaction(transaction)
case .deferred: // transaction is in the queue, but it's final status is pending user/external action
break
}
}
}
func paymentQueueRestoreCompletedTransactionsFinished(_ queue: SKPaymentQueue) {
guard queue.transactions.count > 0 else {
// Queue does not include any transactions, so either user has not yet made a purchase
// or the user's prior purchase is unavailable, so notify app (and user) accordingly.
print("restore queue.transaction.count === 0")
return
}
for transaction in queue.transactions {
// TODO: provide content access here??
print("Product restored with id: (String(describing: transaction.original?.payment.productIdentifier))")
SKPaymentQueue.default().finishTransaction(transaction)
}
}
func paymentQueue(_ queue: SKPaymentQueue, restoreCompletedTransactionsFailedWithError error: Error) {
// fire notification to dismiss spinner, restore error
delegate?.restoreStatusDidUpdate(PurchaseStatus.init(state: .failed, error: error, transaction: nil, message:"Restore Failed."))
}
}
The last piece is a store class that manages triggering purchases and handles granting access to purchased products:
enum ProductIdentifier: String {
case one = "com.myprefix.id1"
case two = "com.myprefix.id2"
static func from(rawValue: String) -> ProductIdentifier? {
switch rawValue {
case one.rawValue: return .one
case two.rawValue: return .two
default: return nil
}
}
}
class Store {
static let shared = Store()
// purchase processor
var paymentProcessor: iTunesStore = iTunesStore()
init() {
// register for purchase status update callbacks
paymentProcessor.delegate = self
validateProducts()
}
// validates products with the iTunesConnect store for faster purchase processing
// when a user wants to buy
internal func validateProducts() {
// all products to validate
let products = [
ProductIdentifier.one.rawValue,
ProductIdentifier.two.rawValue
]
paymentProcessor.fetchStoreProducts(identifiers: Set.init(products))
}
/// Purchase a product by specifying the product identifier.
/