Not Getting Expected Delegate Calls When Trying to Restore In-App Purchases with Storekit

Not getting expected delegate calls when trying to restore in-app purchases with StoreKit

You can use the following modular swift file, UnlockManager.swift in your app to implement in-app-purchases, although I can only guarantee it will work for a single non-consumable product, for example an unlock purchase... otherwise, you'll need to do some modification.

Anyway, here's the meat and potatoes:

UnlockManager.swift:

//  2019 Boober Bunz. No rights reserved.

import StoreKit

protocol UnlockManagerDelegate: class {
func showUnlockPurchaseHasBeenRestoredAlert()
}

class UnlockManager : NSObject, SKProductsRequestDelegate, SKPaymentTransactionObserver {

// Only used in print debugging
// let transactionStateStrings = ["purchasing","purchased","failed","restored","deferred"]

weak var delegate: UnlockManagerDelegate?

private let UNLOCK_IAP_PRODUCT_ID = "your_product_ID_goes_here" // <------------------------------------ ***

private var requestObject: SKProductsRequest?
private var skProductObject: SKProduct?

private var onlineAndReadyToPurchase = false

override init() {
super.init() //important that super.init() comes first
attemptStoreKitRequest()
SKPaymentQueue.default().add(self)
}

deinit {
SKPaymentQueue.default().remove(self)
}

// ------------------------------------------------------------------------------------ STOREKIT CALLBACKS

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

if response.products.count > 0 {
for product in response.products {
if product.productIdentifier == UNLOCK_IAP_PRODUCT_ID {
skProductObject = product
onlineAndReadyToPurchase = true
print("IAP - StoreKit server responded with correct product. We are ready to purchase.")
return // success
}
}
} else { // fail
print("IAP MODULE - on initial request, StoreKit server responded, but \(UNLOCK_IAP_PRODUCT_ID) not found.")
print("IAP MODULE - Check for product ID mismatch.")
print("IAP MODULE - We are not ready to purchase.\n")
}
}

// SKProductsRequestDelegate response (fail)
public func request(_ request: SKRequest, didFailWithError error: Error) {
print("IAP MODULE - on initial request, StoreKit server responded with explicit error: \(error.localizedDescription)")
}


// SKPaymentTransactionObserver calls this
public func paymentQueue(_ queue: SKPaymentQueue,
updatedTransactions transactions: [SKPaymentTransaction]) {

print("IAP MODULE - SKPaymentTransactionObserver called paymentQueue()...")

// print("Transaction Queue:")

for transaction in transactions {
// print("\n PRODUCT ID: \(transaction.payment.productIdentifier)")
// print(" TRANS ID: \(transaction.transactionIdentifier)")
// print(" TRANS STATE: \(transactionStateStrings[transaction.transactionState.rawValue])")
// print(" TRANS DATE: \(transaction.transactionDate)")

// print("\nActions taken as a result of trans.state...")

switch transaction.transactionState {
case .purchased:

if (transaction.payment.productIdentifier == UNLOCK_IAP_PRODUCT_ID) {
print("IAP MODULE - successful purchase of \(UNLOCK_IAP_PRODUCT_ID), so unlocking")
UserDefaults.standard.set(true, forKey: UNLOCK_IAP_PRODUCT_ID)
SKPaymentQueue.default().finishTransaction(transaction)
}
break
case .restored:
guard let productIdentifier = transaction.original?.payment.productIdentifier else { return }
if (productIdentifier == UNLOCK_IAP_PRODUCT_ID) {
if !(appIsUnlocked()){
delegate?.showUnlockPurchaseHasBeenRestoredAlert()
}
print("IAP MODULE - previous purchase of \(UNLOCK_IAP_PRODUCT_ID), so restoring/unlocking")
UserDefaults.standard.set(true, forKey: UNLOCK_IAP_PRODUCT_ID)
SKPaymentQueue.default().finishTransaction(transaction)
}
break
case .failed:
if let transactionError = transaction.error as NSError?,
let localizedDescription = transaction.error?.localizedDescription,
transactionError.code != SKError.paymentCancelled.rawValue {
print("IAP MODULE - ... error in transaction \(transaction.transactionIdentifier ?? "no ID?") for product: \((transaction.payment.productIdentifier)): \(localizedDescription)")
}
SKPaymentQueue.default().finishTransaction(transaction)
break
case .deferred:
break
case .purchasing:
break
default:
break
}
}
}

// ------------------------------------------------------------------------------------ PUBLIC ONLY METHODS
public func purchaseApp() -> Bool {
if !onlineAndReadyToPurchase {
print("IAP MODULE - Purchase attempted but we are not ready!")
return false
}
print("IAP MODULE - Buying \(skProductObject!.productIdentifier)...")
let payment = SKPayment(product: skProductObject!)
SKPaymentQueue.default().add(payment)
return true
}

public func restorePurchases() -> Bool {
if !onlineAndReadyToPurchase {
print("IAP MODULE - User attempted restore, but we are presumbly not online!")
return false
}
SKPaymentQueue.default().restoreCompletedTransactions()
return true
}

public func appIsUnlocked() -> Bool {
return UserDefaults.standard.bool(forKey: UNLOCK_IAP_PRODUCT_ID)
}
// ------------------------------------------------------------------------------------ PUBLIC AND INTERNAL METHODS

// Presumably called on app start-up
// AND (for good measure) when user is presented with purchase dialog
public func attemptStoreKitRequest() {
if !onlineAndReadyToPurchase {
requestObject = SKProductsRequest(productIdentifiers: [UNLOCK_IAP_PRODUCT_ID])
print("IAP MODULE - sending request to StoreKit server for product ID: \(UNLOCK_IAP_PRODUCT_ID)...")
print("IAP MODULE - waiting for response...")
requestObject?.delegate = self
requestObject?.start()
}
}


}

AppStore.sync() not restoring purchases

After releasing to the App Store and finally trying the app directly in production I can confirm that it works, but I have to say that it is not ideal to be unable to test this on the sandbox environment.

Also I feel the documentation was not clear enough, at least not for me.
Probably it is clear for other folks, but I was expecting the purchases to be restored automatically and get them on for await result in Transaction.updates, but this didn't work.

What did work was to check Transaction.currentEntitlements, and if the entitlement is not there, then do a sync() and check again.
This is the code that worked for me:

try? await AppStore.sync()

for await result in Transaction.currentEntitlements {
if case let .verified(transaction) = result {
// ...
}
}

Caveats

  • As mentioned before, this only works on release mode and doesn't work on debug mode without StoreKit Testing, that is without a Configuration.storekit.
  • If you want to use StoreKit Testing (using a Configuration.storekit) restoring purchases works (with this same approach), but only if you don't delete de app and reinstall again. By deleting the app you loose StoreKit Testing history.
  • As mentioned by @loremipsum, this can be tested on TestFlight too (for it being release mode).
  • Verified both iOS and macOS.

iOS: Can't restore consumable purchases in the sandbox environment

Consumable products can't be restored from apple server. For consumable products you will have to manage the purchase list manually at your own server.
If you want restorable products then you can simply do it with Non-Consumable products in iTunes.

iOS StoreKit - When to call - (void)restoreCompletedTransactions?

EDIT:

Originally I had posted a very long, unneeded method to get what I needed done, but as you will see below, Matt helped me figure out the variable I was looking for.

For an example, let's imagine a user previously purchased my app, bought all of the non-consumable IAP's available, then deleted the app. When the user reinstalls the app, I want to be able to determine when they go to "purchase" the products again, will it be an original (first time) purchase, or a restoration purchase?

I have implemented a "Restore all purchases" button, but let's say the user ignores/does not see it, and tries to select a product they have purchased before.

As with a normal purchase, I do the following:

if ([SKPaymentQueue canMakePayments])
{
self.productRequest = [[SKProductsRequest alloc] initWithProductIdentifiers:[NSSet setWithObject:productID]];

self.productRequest.delegate = self;
[self.productRequest start];
}
else
{
//error message here
}

After the user has logged into their iTunes account, the App will let them know they have already purchased this and it will now be restored. The following delegate method will be called:

 -(void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transactions
{
for (SKPaymentTransaction *transaction in transactions)
{
switch (transaction.transactionState)
{
case SKPaymentTransactionStatePurchased:
{
[[SKPaymentQueue defaultQueue] finishTransaction:transaction];
if(transaction.originalTransaction)
{
NSLog(@"Just restoring the transaction");
}
else
{
NSLog(@"First time transaction");
}

break;
}
default:
break;
}
}
}

No matter if the transaction was a restoration or a first time purchase, transaction.transactionState is going to be equal to SKPaymentTransactionStatePurchased.

Now from this point how do we determine if that purchase is an original or restoration purchase?

Simple: As seen above, just see if transaction.originalTransaction is initialized. Per Apple's note: // Only valid if state is SKPaymentTransactionStateRestored.

If the SKPaymentTransaction's originalTransaction is initialized, that means that there was a previous transaction. Otherwise, this transaction is an original one!

Once again, thanks to Matt for pointing me in the right direction, and for making the logic a lot cleaner!

How to properly restore purchases using IAP in Swift

Your implementation of

func paymentQueue(_ pQueue: SKPaymentQueue, updatedTransactions pTransactions: [SKPaymentTransaction]) {

is wrong. You have left out the transaction state for when a purchase is restored! You have case .purchased but you forgot case .restored. Put it in. That is where you are notified and can respond.



Related Topics



Leave a reply



Submit