Search code examples
swiftxcodein-app-purchase

Interrupted Purchase not calling delegate after accepting T&C


Testing interrupted purchases on an actual device, item #10 below doesn't come up within the same session. It will only come up when

  1. The app is restarted
  2. The app goes to background and then back to foreground (for #2, I presume that this is because, when app goes back to foreground, the TransactionObserver is called again?)

This SO also talks about something similar: Apple In App Purchase, Interrupted purchase in sandbox

https://developer.apple.com/documentation/storekit/original_api_for_in-app_purchase/testing_in-app_purchases_with_sandbox/testing_an_interrupted_purchase enter image description here


Solution

  • I've been trying to find a solution to this situation for many many weeks. Going thru many SO and also threads from apple forums.

    Ref threads:

    1. https://developer.apple.com/forums/thread/674081
    2. https://developer.apple.com/forums/thread/671492
    3. https://developer.apple.com/forums/thread/685938
    4. Apple In App Purchase, Interrupted purchase in sandbox

    Per Apple Docs, an interrupted transaction is supposed to send a FAIL then a PURCHASED after the user has agreed to the T&C/continued the interrupted purchase (but in reality it doesn't seem to happen - refer linked threads above)

    This is what I ended up with as my current solution. It's not entirely ideal but it's the best (I have found) given the circumstances. Refer to link #3 where the user observed that calling restoreCompletedTransactions() would actually be able to get the transaction observer to process the (completed) interrupted transaction. Doing it this way, the user doesn't have to close (to background) and then open the app back up again. (Note that I tried various methods of calling the transaction observer again but all of it didn't help)

    This is my solution.

        private func purchaseFailed(_ transaction: SKPaymentTransaction) {
            var failureReason: String = ""
            var message: String = ""
            var code = Int()
            
            print("\(FormatDisplay.datems(Date())) [IAP] Transcation FAILED/CANCELLED")
            
            // https://stackoverflow.com/q/55641652/14414215
            // https://adapty.io/blog/ios-in-app-purchases-part-5-list-of-skerror-codes-and-how-to-handle-them
            if let skError = transaction.error as? SKError {
                switch skError.code {  // https://developer.apple.com/reference/storekit/skerror.code
                case .unknown:
                    // https://developer.apple.com/forums/thread/674081
                    if let underlyingError = skError.userInfo["NSUnderlyingError"] as? NSError {
                       if underlyingError.code == 3038 {
                         print(">> General conditions have changed, don't display an error for the interrupted transaction")
                        failureReason = "ERROR: Unknown Error. Transaction Interrupted"
                        message = "Transaction Interrupted. Your Purchase is being processed. Please Check Back in 5mins."
                        code = underlyingError.code
                        
                       } else {
                        failureReason = "Unknown or unexpected error occurred"
                        message = "Oops, something unknown occurred or the transaction was interrupted. If the interrupted purchase was successful, please check back in 5mins."
                        code = skError.code.rawValue
                       }
                    }
                    break
                case .clientInvalid:
                    failureReason = "ERROR: Invalid Client"
                    message = "The purchase cannot be completed. Please, change your account or device."
                    code = skError.code.rawValue
                    break
                case .paymentCancelled:
                    failureReason = "ERROR: User Cancelled Payment"
                    message = ""
                    code = skError.code.rawValue
                    break
                case .paymentInvalid:
                    failureReason = "ERROR: Invalid Payment"
                    message = "Your purchase was declined. Please, check the payment details and make sure there are enough funds in your account."
                    code = skError.code.rawValue
                    break
                case .paymentNotAllowed:
                    failureReason = "ERROR: Payment not allowed"
                    message = "The purchase is not available for the selected payment method. Please, make sure your payment method allows you to make online purchases."
                    code = skError.code.rawValue
                    break
                case .storeProductNotAvailable:
                    failureReason = "ERROR: Store product not available"
                    message = "This product is not available in your region. Please, change the store and try again"
                    code = skError.code.rawValue
                    break
                case .cloudServicePermissionDenied:
                    failureReason = "ERROR: Cloud service permission denied"
                    message = "Your purchase was declined"
                    code = skError.code.rawValue
                    break
                    
                case .cloudServiceNetworkConnectionFailed:
                    failureReason = "ERROR: Cloud service network connection failed"
                    message = "he purchase cannot be completed because your device is not connected to the Internet. Please, try again later with a stable internet connection"
                    code = skError.code.rawValue
                    break
                case .cloudServiceRevoked:
                    failureReason = "ERROR: Cloud service revoked"
                    message = "Sorry, an error has occurred."
                    code = skError.code.rawValue
                    break
                case .privacyAcknowledgementRequired:
                    failureReason = "ERROR: Privacy Acknowledgement Required"
                    message = "The purchase cannot be completed because you have not accepted the terms of use of the AppStore. Please, confirm your consent in the settings and then return to the purchase."
                    code = skError.code.rawValue
                    break
                case .unauthorizedRequestData:
                    failureReason = "ERROR: Unauthorized Request Data"
                    message = "An error has occurred. Please, try again later."
                    code = skError.code.rawValue
                    break
                case .invalidOfferIdentifier:
                    failureReason = "ERROR: Invalid offer identifier"
                    message = "The promotional offer is invalid or expired."
                    code = skError.code.rawValue
                    break
                case .invalidSignature:
                    failureReason = "ERROR: Invalid Signature"
                    message = "Sorry, an error has occurred when applying the promo code. Please, try again later."
                    code = skError.code.rawValue
                    break
                case .missingOfferParams:
                    failureReason = "ERROR: Missing offer params"
                    message = "Sorry, an error has occurred when applying the promo code. Please, try again later."
                    code = skError.code.rawValue
                    break
                case .invalidOfferPrice:
                    failureReason = "ERROR: Invalid offer price"
                    message = "Sorry, your purchase cannot be completed. Please, try again later."
                    code = skError.code.rawValue
                    break
                case .overlayCancelled:
                    failureReason = "ERROR: overlay Cancelled"
                    message = ""
                    code = skError.code.rawValue
                    break
                case .overlayInvalidConfiguration:
                    failureReason = "ERROR: Overlay Invalid Configuration"
                    message = ""
                    code = skError.code.rawValue
                    break
                case .overlayTimeout:
                    failureReason = "ERROR: Overlay Timeout"
                    message = ""
                    code = skError.code.rawValue
                    break
                case .ineligibleForOffer:
                    failureReason = "ERROR: Ineligible Offer"
                    message = "Sorry, your purchase cannot be completed. Please, try again later."
                    code = skError.code.rawValue
                    break
                case .unsupportedPlatform:
                    failureReason = "ERROR: Unsupported Platform"
                    message = "Sorry, unsupported Platform"
                    code = skError.code.rawValue
                    break
                case .overlayPresentedInBackgroundScene:
                    failureReason = "ERROR: Overlay Presented In Background Scene"
                    message = ""
                    code = skError.code.rawValue
                    break
                @unknown default:
                    failureReason = "ERROR: Unknown Default"
                    message = "Oops. Something Has Happened. Please try again later."
                    code = skError.code.rawValue
                    break
                }
                let title = "Error"
                let errorMsg = failureReason
                if message != "" {
                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
                        DisplayAlert.presentIapFailed(title: title, message: message, errMsg: errorMsg)
                        
                    }
                }
                
                failureReason += " ErrorCode: \(code)"
            }
            
            print("\(FormatDisplay.datems(Date())) [IAP] \(failureReason)")
            print("\(FormatDisplay.datems(Date())) [IAP] -- \(transaction.error?.localizedDescription ?? "")")
            print("\(FormatDisplay.datems(Date())) [IAP] -- \(transaction.error.debugDescription)")
            print("\(FormatDisplay.datems(Date())) [IAP] -- Calling SKPaymentQ.FinishTransaction")
            
            SKPaymentQueue.default().finishTransaction(transaction)
            purchaseCompletionHandler?(true)
        }
    

    This is the content of purchaseIapFailed() that calls the restoreCompletedPurchases() once the user presses the OK Button.

    static func presentIapFailed(title: String, message: String, errMsg: String) {
        let root = UIApplication.shared.keyWindow?.rootViewController
        let alertController = UIAlertController(title: title,
                                                message: message,
                                                preferredStyle: .alert)
        
        alertController.addAction(UIAlertAction(title: "OK", style: .cancel, handler: {( action: UIAlertAction ) in
            print("    displayAlert - presentIapFailed: \(errMsg) - OK Pressed")
            
            // Call RestorePurchases to force replaying the transaction list.
            Medals.store.restorePurchases()
        }))
        
        root?.present(alertController, animated: true, completion: nil)
    }
    

    once the transaction is "restored", then it will continue to trigger the subsequent codes to follow through with the purchase, send a notification (of purchase completion/success) and then an alert message will pop up telling the user that the purchase is successful and the consumable has been credited.