user
user

Reputation: 83

In-app purchase in application

I have several in-app purchases in application. I use this code:

@IBAction func purchaseFull(_ sender: Any) {  

        purchase = "purchaseFull"

        product_id = "purchaseFull"

        print("About to fetch the product...")
        //self.loading.startAnimating()
        SKPaymentQueue.default().add(self)
        // Can make payments
        if (SKPaymentQueue.canMakePayments())
        {
            let productID:NSSet = NSSet(object: self.product_id!);
            let productsRequest:SKProductsRequest = SKProductsRequest(productIdentifiers: productID as! Set<String>);
            productsRequest.delegate = self;
            productsRequest.start();
            print("Fetching Products");
        }else{
            print("Can't make purchases");
        }
    }

@IBAction func purchase(_ sender: Any) {

        purchase = "purchase"

        product_id = "purchase\(index)"

        print("About to fetch the product...")
        //self.loading.startAnimating()
        SKPaymentQueue.default().add(self)
        // Can make payments
        if (SKPaymentQueue.canMakePayments())
        {
            let productID:NSSet = NSSet(object: self.product_id!);
            let productsRequest:SKProductsRequest = SKProductsRequest(productIdentifiers: productID as! Set<String>);
            productsRequest.delegate = self;
            productsRequest.start();
            print("Fetching Products");
        }else{
            print("Can't make purchases");
        }
    }

func productsRequest (_ request: SKProductsRequest, didReceive response: SKProductsResponse) {

        let count : Int = response.products.count
        if (count>0) {
            let validProduct: SKProduct = response.products[0] as SKProduct
            if (validProduct.productIdentifier == self.product_id) {
                print(validProduct.localizedTitle)
                print(validProduct.localizedDescription)
                print(validProduct.price)
                buyProduct(product: validProduct);

            } else {
                print(validProduct.productIdentifier)
            }
        } else {
            print("nothing")
        }
    }

    func buyProduct(product: SKProduct){
        print("Sending the Payment Request to Apple");
        let payment = SKPayment(product: product)
        SKPaymentQueue.default().add(payment);
        //self.loading.stopAnimating()
    }

    func request(_ request: SKRequest, didFailWithError error: Error) {
        print("Error Fetching product information");
        //self.loading.stopAnimating()
    }

    func paymentQueue(_ queue: SKPaymentQueue,
                      updatedTransactions transactions: [SKPaymentTransaction]) {
        print("Received Payment Transaction Response from Apple");

        for transaction:AnyObject in transactions {
            if let trans:SKPaymentTransaction = transaction as? SKPaymentTransaction{
                switch trans.transactionState {
                case .purchased:
                    print("Product Purchased");
                    SKPaymentQueue.default().finishTransaction(transaction as! SKPaymentTransaction)
                    // Handle the purchase

                    if purchase == "purchase" {
                        UserDefaults.standard.set(true , forKey: "purchase\(index)")
                    }

                    if purchase == "purchaseFull" {
                        UserDefaults.standard.set(true , forKey: "purchaseFull")
                    }

                    viewDidLoad()
                    break;
                case .failed:
                    print("Purchased Failed");
                    SKPaymentQueue.default().finishTransaction(transaction as! SKPaymentTransaction)
                    break;

                case .restored:
                    print("Already Purchased");
                    SKPaymentQueue.default().restoreCompletedTransactions()
                    // Handle the purchase
                    //UserDefaults.standard.set(true , forKey: "purchased")
                    viewDidLoad()
                    break;
                default:
                    break;
                }
            }
        }
    }

    @IBAction func restoreAction(_ sender: Any) {
        SKPaymentQueue.default().add(self)
        if (SKPaymentQueue.canMakePayments()) {
            SKPaymentQueue.default().restoreCompletedTransactions()
        }
    }

    func requestDidFinish(_ request: SKRequest) {

    }

    func paymentQueueRestoreCompletedTransactionsFinished(_ queue: SKPaymentQueue) {
        print("transactions restored")
        for transaction in queue.transactions {
            let t: SKPaymentTransaction = transaction
            let prodID = t.payment.productIdentifier as String

            if prodID == "purchaseFull" {
                print("action for restored")
                queue.finishTransaction(t)
                UserDefaults.standard.set(true , forKey: "purchaseFull")
            } else if prodID == "purchase0" {
                print("action0")
                queue.finishTransaction(t)
                UserDefaults.standard.set(true , forKey: "purchase0")
            } else if prodID == "purchase1" {
                print("action1")
                queue.finishTransaction(t)
                UserDefaults.standard.set(true , forKey: "purchase1")
            } else if prodID == "purchase2" {
                print("action2")
                queue.finishTransaction(t)
                UserDefaults.standard.set(true , forKey: "purchase2")
            } else if prodID == "purchase3" {
                print("action3")
                queue.finishTransaction(t)
                UserDefaults.standard.set(true , forKey: "purchase3")
            } else if prodID == "purchase4" {
                print("action4")
                queue.finishTransaction(t)
                UserDefaults.standard.set(true , forKey: "purchase4")
            } else if prodID == "purchase5" {
                print("action5")
                queue.finishTransaction(t)
                UserDefaults.standard.set(true , forKey: "purchase5")
            }
        }
        cancelAction((Any).self)
    }

But I have a problem. When I click on my purchase button my code call this function - paymentQueueRestoreCompletedTransactionsFinished(_ queue: SKPaymentQueue) and if else check works. And my Userdefaults set true for key. As a result user unblock content but not pay for purchases. How to fix it?

Upvotes: 0

Views: 1998

Answers (1)

digitalHound
digitalHound

Reputation: 4444

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.
    ///
    /// - Parameter identifier: The product identifier for the product being purchased. This must belong to a valid product in the 'products' array, as this array is searched for a product with the specified identifier. If none are found this function bails.
    func purchaseProduct(identifier: ProductIdentifier) {
        print("purchase product: \(identifier)")

        self.paymentProcessor.purchaseProduct(identifier: identifier.rawValue)
    }

    /// Initiates restore purchases functionality.
    func restorePurchases() {
        self.paymentProcessor.restorePurchases()
    }

    /// This function is called during the purchase/restore purchase process with each status change in the flow. If the status is complete then access to the product should be granted at this point.
    ///
    /// - Parameter status: The current status of the transaction.
    internal func processPurchaseStatus(_ status: PurchaseStatus) {
        switch status.state {
        case .initiated:
            // TODO: show alert that purchase is in progress...
            break
        case .complete:
            if let productID = status.transaction?.payment.productIdentifier {
                // Store product id in UserDefaults or some other method of tracking purchases
                UserDefaults.standard.set(true , forKey: productID)
                UserDefaults.standard.synchronize()
            }
        case .cancelled:
            break
        case .failed:
            // TODO: notify user with alert...
            break
        }
    }
}

extension Store: iTunesPurchaseStatusReceiver, iTunesProductStatusReceiver {
    func purchaseStatusDidUpdate(_ status: PurchaseStatus) {
        // process based on received status
        processPurchaseStatus(status)
    }

    func restoreStatusDidUpdate(_ status: PurchaseStatus) {
        // pass this into the same flow as purchasing for unlocking products
        processPurchaseStatus(status)
    }

    func didValidateProducts(_ products: [SKProduct]) {
        print("Product identifier validation complete with products: \(products)")
        // TODO: if you have a local representation of your products you could
        // sync them up with the itc version here
    }

    func didReceiveInvalidProductIdentifiers(_ identifiers: [String]) {
        // TODO: filter out invalid products? maybe... by default isActive is false
    }
}

By using this approach you'll be able to put all the iTunesConnect related classes in a folder and use them across projects and only have to update your ProductIdentifer enum and Store class for each project you want to use it in.

Good luck! Hope this helps!

EDIT: Here's a sample application (Swift 4) with everything integrated showing how to use it - https://github.com/appteur/eziap

EDIT 2: (Answer to comment regarding displaying alert)

There are several ways you can display an alert notifying the user.

You could trigger an alert in any view controller by sending a Notification and having the view controller listen for it. You could setup a delegate chain to the appropriate view controller, you could also create closures that would be passed to the Store object and updated on state changes.

I would probably create an optional custom alert view controller and set a variable in the Store class of it's type and present it on the top most view controller.

I would present it in the internal func processPurchaseStatus(_ status: PurchaseStatus) function on the initiated state and update it when the state changes in the same function.

I would update the label on the alert view with the current status and have it automatically dismiss on successful purchase or display a success screen. If the transaction failed I would update the alert message with the error and reveal a button to dismiss the alert view.

I use an extension like this to get the top most view controller:

import UIKit

extension UIApplication {
    static func topViewController(controller: UIViewController? = UIApplication.shared.keyWindow?.rootViewController) -> UIViewController? {
        if let navigationController = controller as? UINavigationController {
            return topViewController(controller: navigationController.visibleViewController)
        }
        if let tabController = controller as? UITabBarController {
            if let selected = tabController.selectedViewController {
                return topViewController(controller: selected)
            }
        }
        if let presented = controller?.presentedViewController {
            return topViewController(controller: presented)
        }
        return controller
    }
}

Using this method you would probably have a function something like this:

internal func showAlert() {

    // do UI stuff on the main thread
    DispatchQueue.main.async { [weak self] in

        // load alert from storyboard, nib, or some method, get the topmost view controller in the controller hierarchy, only use it if it's not being dismissed
        guard let alertVC = MyAlertClass.fromNib(), let topVC = UIApplication.topViewController(), topVC.isBeingDismissed == false else {
            return
        }

        // set our local variable so we can update the message later and dismiss it more easily
        self.myAlertController = alertVC

        // Present the alert view controller
        topVC.present(alertVC, animated: true, completion: nil)
    }
}

Instead of doing a custom alert view controller you could also just create a view subclass with your alert UI and add it to the view of the top most view controller.

Upvotes: 2

Related Questions