17

In rare cases it seems that some of my users are unable to make a non-consumable purchase. When they attempt to purchase it doesn't activate "premium" and when they restore from either their current install or a fresh install paymentQueue: updatedTransactions: is not called.

I've added a lot of logging specifically to try and determine why the restore is not following an expected flow. During a failed restore none of the "RESTORE" category events are fired.

For reference [self success]; just displays the content view and [self fail:] displays an error message to the user instead.

Also [[SKPaymentQueue defaultQueue] addTransactionObserver:self]; is called in viewDidLoad and [[SKPaymentQueue defaultQueue] restoreCompletedTransactions]; is called on button press.

- (void)paymentQueueRestoreCompletedTransactionsFinished:(SKPaymentQueue *)queue {
    // COMPLETION POINT - RESTORE COMPLETE***
    [MBProgressHUD hideHUDForView:self.view animated:TRUE];

    if ([SKPaymentQueue defaultQueue].transactions.count == 0) {
        [self.tracker send:[[GAIDictionaryBuilder createEventWithCategory:@"RESTORE"
                                                                   action:@"failure_hard"
                                                                    label:@"no_purchases"
                                                                    value:nil] build]];
        [self fail:@"There are no items available to restore at this time."];
    } else {
        [self success];
    }
}

- (void)paymentQueue:(SKPaymentQueue *)queue restoreCompletedTransactionsFailedWithError:(NSError *)error {
    // COMPLETION POINT - RESTORE FAILED
    [MBProgressHUD hideHUDForView:self.view animated:TRUE];

    [self.tracker send:[[GAIDictionaryBuilder createEventWithCategory:@"RESTORE"
                                                               action:@"failure_hard"
                                                                label:error.localizedDescription
                                                                value:nil] build]];
    [self fail:error.localizedDescription];
}

- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transactions {
    // Make sure completion states call [[SKPaymentQueue defaultQueue] finishTransaction:transaction];
    // in order to prevent sign in popup
    // http://stackoverflow.com/a/10853107/740474
    [MBProgressHUD hideHUDForView:self.view animated:TRUE];
    for (SKPaymentTransaction *transaction in transactions) {
        switch (transaction.transactionState) {
            case SKPaymentTransactionStatePurchasing:
                break;
            case SKPaymentTransactionStateDeferred:
                break;
            case SKPaymentTransactionStateFailed:
                // COMPLETION POINT - PURCHASE FAILED
                [self.tracker send:[[GAIDictionaryBuilder createEventWithCategory:@"PURCHASE"
                                                                           action:@"failure_hard"
                                                                            label:transaction.error.localizedDescription
                                                                            value:nil] build]];
                if (transaction.error.code != SKErrorPaymentCancelled) {
                    // only show error if not a cancel
                    [self fail:transaction.error.localizedDescription];
                }
                [[SKPaymentQueue defaultQueue] finishTransaction:transaction];
                break;
            case SKPaymentTransactionStatePurchased:
                // COMPLETION POINT - PURCHASE SUCCESS
                if ([transaction.payment.productIdentifier isEqualToString:(NSString*)productID]) {
                    // premium purchase successful
                    [self.tracker send:[[GAIDictionaryBuilder createEventWithCategory:@"PURCHASE"
                                                                               action:@"success"
                                                                                label:nil
                                                                                value:nil] build]];
                    [Utils setPremium:YES];
                    [self success];
                } else {
                    [self.tracker send:[[GAIDictionaryBuilder createEventWithCategory:@"PURCHASE"
                                                                               action:@"failure_hard"
                                                                                label:@"no_id"
                                                                                value:nil] build]];
                    [self fail:@"The item you purchased was not returned from Apple servers. Please contact us."];
                }
                [[SKPaymentQueue defaultQueue] finishTransaction:transaction];
                break;
            case SKPaymentTransactionStateRestored:
                if ([transaction.payment.productIdentifier isEqualToString:(NSString*)productID]) {
                    // premium purchase restored
                    [self.tracker send:[[GAIDictionaryBuilder createEventWithCategory:@"RESTORE"
                                                                               action:@"restore_success"
                                                                                label:nil
                                                                                value:nil] build]];
                    [Utils setPremium:YES];
                } else {
                    [self.tracker send:[[GAIDictionaryBuilder createEventWithCategory:@"RESTORE"
                                                                               action:@"failure_hard"
                                                                                label:@"no_id"
                                                                                value:nil] build]];
                }
                [[SKPaymentQueue defaultQueue] finishTransaction:transaction];
                break;
            default:
                // For debugging
                   [self.tracker send:[[GAIDictionaryBuilder createEventWithCategory:@"STORE"
                                                                           action:@"transaction_weird"
                                                                               label:[NSString stringWithFormat:@"Unexpected transaction state %@", @(transaction.transactionState)]
                                                                            value:nil] build]];
                break;
        }
    }
}

Any suggestions would be appreciated

alexgophermix
  • 4,189
  • 5
  • 32
  • 59
  • Did you ever get this resolved? I am having the same issue. paymentQueue: updatedTransactions: not called during restore operation. There are no errors and paymentQueueRestoreCompletedTransactionsFinished is called so my observer must be correct. – btschumy Jan 10 '18 at 18:19
  • Unfortunately no. Only a few rare users are affected and we can't find out what's going on. It honestly seems like they purchased and the purchase was silently cancelled/failed and it won't let them buy again giving them the impression that they had purchased but weren't receiving the product. Might be some kind of chargeback exploit? I honestly have no idea, all I can tell is they're saying they made the purchase and are not getting a restore. – alexgophermix Jan 10 '18 at 21:44
  • I did just figure this out (I think). It turns out we have two kinds of IAP, ones that are downloadable and ones that aren't. The code that was used was just testing to see if transaction.downloadable was nil to distinguish the two. I believe that the downloadable array can be non-nil but empty and you have to allow for that case. If we dropped into the downloadable code with a non-downloadable IAP, the transaction was never finished. – btschumy Jan 11 '18 at 22:56
  • Check in iOS version 13 onwards https://stackoverflow.com/a/66343882/239485 – Mohan Feb 24 '21 at 02:38

6 Answers6

9

Is there any chance you are using Firebase analytics in your app?

https://firebase.google.com/docs/analytics/ios/start says

If you're tracking in-app purchases, you must initialize your transaction observer in application:didFinishLaunchingWithOptions: before initializing Firebase, or your observer may not receive all purchase notifications. See Apple's In-App Purchase Best Practices for more information.

In this case the suggestion is to init your observer before initializing Firebase analytics.

Here is a blogpost with additional details: https://www.greensopinion.com/2017/03/22/This-In-App-Purchase-Has-Already-Been-Bought.html

yblinov
  • 91
  • 1
  • 2
4

have you implemented below method:

- (void)request:(SKRequest *)request didFailWithError:(NSError *)error NS_AVAILABLE_IOS(3_0);

It's one of the optional methods from SKRequestDelegate.

We also were facing the same problem of missing restore purchase call. Handling this delegate helped us. All the requests which were not even being delivered to queue due to whatever reason, were delivered in this failure delegate.

So,I think you might be facing the same issue.

pkamb
  • 33,281
  • 23
  • 160
  • 191
Vikas Dadheech
  • 1,672
  • 12
  • 23
  • I do have this method. I think this method is only triggered for a failing `SKProductRequest` not for payment or restore cases – alexgophermix Jul 19 '17 at 17:19
  • that's the thing, all the requests that are delivered to itunes, do get a callback in updatedTransaction method. Its the requests that does not even get delivered to itunes were what were causing trouble in our case. So you can trying handling failing request from here. – Vikas Dadheech Jul 20 '17 at 15:18
3

Start the restore process -

-(void)restore{
    isRestored = false;
    [[SKPaymentQueue defaultQueue] addTransactionObserver:self];
    [[SKPaymentQueue defaultQueue] restoreCompletedTransactions];
}

If any transaction is successfully restored the following method is called:

-(void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray<SKPaymentTransaction *> *)transactions{
       case SKPaymentTransactionStateRestored:

            DDLogVerbose(@"Restored");
            //Check with your product id if it is the right product that you want to restore
            if ([transaction.payment.productIdentifier isEqualToString:IAP_PRODUCT_ID]) {
                isRestored = true;
                // Successfully restored the payment, provide the purchased content to the user.
            }
            [[SKPaymentQueue defaultQueue] finishTransaction:transaction];
            break;
}

When payment queue has finished sending restored transactions, following method is called (If it is called means its completed the transaction and not that restore is success)-

-(void)paymentQueueRestoreCompletedTransactionsFinished:(SKPaymentQueue *)queue{
    DDLogVerbose(@"Restore completed");

    if (isRestored) {
        // Successfully restored
        } else {
        // No transaction to restore
    }
}

paymentQueueRestoreCompletedTransactionsFinished

When any error occurred while restoring transactions, following method is called -

- (void)paymentQueue:(SKPaymentQueue *)queue restoreCompletedTransactionsFailedWithError:(NSError *)error{

    DDLogVerbose(@"Error in restoring:%@",error);

    if (error.code == 0) {
        // unable to connect to iTunes
    }
}
pkamb
  • 33,281
  • 23
  • 160
  • 191
Pooja Gupta
  • 785
  • 10
  • 22
  • 1
    From what I can tell this is exactly what I'm doing. However `restoreCompletedTransactionsFailedWithError` is not being called and the case `SKPaymentTransactionStateRestored` isn't being hit – alexgophermix Jul 14 '17 at 15:42
  • Are you also facing the problem with fresh transaction or only with the restore transaction? – Pooja Gupta Jul 17 '17 at 11:02
  • I believe this also affects fresh transactions but I've only been dealing with the two restore cases over the past few days. As mentioned above though the vast majority of users seem to be unaffected – alexgophermix Jul 17 '17 at 23:15
3

Few steps to make your application behaviour as expected :

1. Add transaction observers in AppDelegate which keeps track of delayed response & whenever your application gets launched it will update & finish the transaction in queue

[[SKPaymentQueue defaultQueue] addTransactionObserver:self];

Remove observer in applicationWillTerminate

[[SKPaymentQueue defaultQueue] removeTransactionObserver:self];

2. For second step, check in-app receipt before asking from user to purchase

-(void)validateReceiptsFromAppStoreFor:(NSString *)productTag completionBlock:(void (^)(NSDictionary *receiptResponse,NSError *error))completion
{
    //check if receipt exists in app bundle
    //else request for refresh receipt data..
    //if receipt exists,verify with server & check if product tag exists in receipt & send receipt response as success msg
    //else wait for refresh request success 
    NSURL *receiptURL = [[NSBundle mainBundle] appStoreReceiptURL];
    NSData *receipt = [NSData dataWithContentsOfURL:receiptURL];
    if (!receipt)
    { /* No local receipt -- handle the error. */
        refreshRequest = [[SKReceiptRefreshRequest alloc] initWithReceiptProperties:nil];
        refreshRequest.delegate = self;
        [refreshRequest start];
        return;
    }

    /* ... Send the receipt data to your server ... */
    NSError *error;
    NSDictionary *requestContents = @{
                                      @"password":@"Your shared secret key",
                                        @"receipt-data": [receipt base64EncodedStringWithOptions:0]
                                      };
    NSData *requestData = [NSJSONSerialization dataWithJSONObject:requestContents
                                                          options:0
                                                            error:&error];

    if (!requestData) { /* ... Handle error ... */ }

    // Create a POST request with the receipt data.
    NSString *storeURL = SANDBOX_VERIFY_RECEIPT_URL; //ITMS_PROD_VERIFY_RECEIPT_URL;
    if ([[[FFGDefaults sharedDefaults] objectForKey:@"environmentType"] isEqualToString:@"prod"])
    {
        storeURL = PROD_VERIFY_RECEIPT_URL;
    }
    NSMutableURLRequest *storeRequest = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:storeURL]];
    [storeRequest setHTTPMethod:@"POST"];
    [storeRequest setHTTPBody:requestData];

    // Make a connection to the iTunes Store on a background queue.
    NSOperationQueue *queue = [[NSOperationQueue alloc] init];
    [NSURLConnection sendAsynchronousRequest:storeRequest queue:queue
                           completionHandler:^(NSURLResponse *response, NSData *data, NSError *connectionError) {
                               if (connectionError) {
                                   /* ... Handle error ... */
                                   if (completion) {
                                       completion(nil,connectionError);
                                   }
                               } else {
                                   NSError *error;
                                   NSDictionary *jsonResponse = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];
                                   if (completion)
                                   {
                                       jsonResponse = jsonResponse[@"receipt"];
                                       if ([jsonResponse[@"bundle_id"] isEqualToString:[NSBundle mainBundle].bundleIdentifier])
                                       {                                           
                                               //check if product was purchased earlier..
                                               NSString *str_productID = [CFCommonUtils productIDForPlanTag:productTag];
                                                NSArray *receiptArr = jsonResponse[@"in_app"];
                                               if (receiptArr && receiptArr.count>0)
                                               {
                                                   NSArray *filteredArray = [receiptArr filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"product_id = %@",str_productID]];
                                                   if (filteredArray.count>0) {
                                                       completion(jsonResponse,error);
                                                   }
                                                   else
                                                   {
                                                       NSError *err = [NSError errorWithDomain:@"" code:100 userInfo:nil];
                                                       completion(nil,err);
                                                   }

                                               }
                                               else
                                               {
                                                   NSError *err = [NSError errorWithDomain:@"" code:100 userInfo:nil];
                                                   completion(nil,err);
                                               }

                                       }
                                       else
                                       {
                                           NSError *err = [NSError errorWithDomain:@"" code:100 userInfo:nil];
                                           completion(nil,err);

                                       }
                                   }

                               }
                               }];

}

3. Handle Receipt refresh delegate methods to check receipt from updated one

- (void)request:(SKRequest *)request didFailWithError:(NSError *)error
{
#ifdef DEBUG
    NSLog(@"SKRequest : didFailWithError :%@",error);
#endif
    if ([request isMemberOfClass:[SKReceiptRefreshRequest class]] && refreshRequest && delegate && [delegate respondsToSelector:@selector(receiptRefreshed:error:)])
    {
        [self receiptRefreshed:self error:error];
        refreshRequest = nil;
    }
    else
    {

    }
}

- (void)requestDidFinish:(SKRequest *)request
{
#ifdef DEBUG
    NSLog(@"SKRequest : requestDidFinish ");
#endif
    if ([request isMemberOfClass:[SKReceiptRefreshRequest class]] && refreshRequest && delegate && [delegate respondsToSelector:@selector(receiptRefreshed:error:)])
    {
        [self receiptRefreshed:self error:nil];
        refreshRequest = nil;
    }
    else
    {
    }
}



-(void) receiptRefreshed:(CFStorekitManager*)ebp error:(NSError *)error
{
    if (error)
    {
    }
    else
    {
        [self validateSubscriptionReceiptsFromAppStoreWithRefreshReceipt:YES completion:^(NSDictionary *receiptResponse, NSError *error)
     {
         dispatch_async(dispatch_get_main_queue(), ^{
             if (error)
             {
                 //show subscription for purchase
             }
             else
             {
             }
         });

     }];

    }
}
Ellen
  • 5,180
  • 1
  • 12
  • 16
1

I'm not sure if this has something to do with your issue but it might be the cause. (Or at the very least it is the recommended thing to do by apple).

Apple Documentation

Apple recommends registering the SKPaymentQueue as an observer in your AppDelegate, not in a specific class (unless you call this class in the AppDelegate itself)

This means that this:

[[SKPaymentQueue defaultQueue] addTransactionObserver:self];

Should actually go inside the applicationDidFinishLaunchingWithOptions method within your AppDelegate.

Why is this important? You mentioned that:

During a failed restore none of the "RESTORE" category events are fired.

This makes me believe that your listeners are not being registered properly, or on time. (or in a very rare case, users might be being redirected outside your app to login properly or something, and by memory issues your app might get killed? either way this ensures that as soon as they return back to your app there will always be an observer ready to process any notifications send by Apple)


Restoring purchases using the App Receipt

Documentation here.

Alternatively, it's much easier to implement the restore purchases logic by refreshing the app receipt and then delivering content based on what the user has purchases.

request = [[SKReceiptRefreshRequest alloc] init];
request.delegate = self;
[request start];

This will call your delegate methods:

func requestDidFinish(SKRequest)

or

func request(SKRequest, didFailWithError: Error)

Once the request has successfully finished, you can parse the receipt to grant the user all of the previously purchased items. The receipt parsing guide is described here.

Pochi
  • 13,391
  • 3
  • 64
  • 104
  • 1
    That's a good suggestion however the HUD is being hidden for the user so at least one of the delegate methods are being called. My guess is that for the restore case either `paymentQueueRestoreCompletedTransactionsFinished` is fired with `[SKPaymentQueue defaultQueue].transactions.count != 0` **OR** updatedTransactions is called and the switch statement drops into a case other than `SKPaymentTransactionStateRestored`. Is it possible for `paymentQueueRestoreCompletedTransactionsFinished` to be triggered with 1 or more transactions yet updatedTransactions is skipped? – alexgophermix Jul 18 '17 at 19:03
  • The way you are restoring purchases is pre ios 7, in that way the restore procedure mimics the behavior of the user purchasing stuff again, so purchases are treated as new. The "paymentQueueRestoreCompletedTransactionsFinished" is used to tell you that all purchase transactions have been sent to you, so I don't think you are supposed to check for the queue count there. Anyways, I strongly suggest you change your restore logic to use the "receipt" for restoring purchases. This is also the Apple's recommended way. It is also a lot simpler and more reliable. Check my updated answer. – Pochi Jul 19 '17 at 01:19
  • From what I've seen it's ok to check queue count and handle there. I've seen receipts before but restore transaction doesn't appear to be deprecated. I may have missed a FinishTransanction case in an older version. Is it possible that there is an unfinished transaction for these users which is why they can't purchase or restore? If so how would I check? – alexgophermix Jul 19 '17 at 20:37
  • If there are any unfinished transactions they are automatically delivered to your app on launch, which is why your listener should be registered at your app delegate. Still, i don't think that's the issue because they would be delivered to your currently implemented methods when you press the restore process. As you said, the way you are using is certainly not deprecated, but checking the receipt is better since iOS 7. – Pochi Jul 20 '17 at 00:52
1

From what I can see, if the restoreCompletedTransactions() call completes but does not result in any transactions being restored, paymentQueue(_:, updateTransactions:) is not called.

func paymentQueueRestoreCompletedTransactionsFinished(_ queue: SKPaymentQueue) {
    if queue.transactions.count == 0 {
        // does NOT call paymentQueue:updatedTransactions:
    } else {
        // should call paymentQueue:updatedTransactions:
    }
}

So we can check queue.transactions.count == 0 to determine if the other delegate method will be called.

pkamb
  • 33,281
  • 23
  • 160
  • 191