Reputation: 83
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
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