Consumable product "already purchased"?

I sell several consumable in-game items in my app.


Roughly translated, my user saw this pop up from Apple saying "It is already purchased" when trying to buy a consumable product.


I am not sure why this user sees this message because consumable products are supposed to be able to be purchased multiple times.


At any rate, I don't see any evidence that the user successfully purchased this item in my logs (see below -- I did have to remove some "invalid characters" (Korean hangul) from the log output in order to post on this site, argh), so I am confused why the IAP system seems to believe that this user already purchased it. From my app's perspective, there weren't any purchasing events at all -- her in-game credits do not reflect her having this "consumable."


I even asked the user to check their credit card statement, and she did not see any charges.


If it helps, I am happy to share the Objective-C code for my app's IAP system. Many users have already successfully purchased my consumables on iOS using IAP in the past, so this issue has been a rare first time one-off for us.

Today, one of my users saw this message today (from Apple, I didn't write code to display this modal):


INFO IAP: OBJC initiatePurchase: 5hoursenglish subscription:false
WARNING IAP: Purchase purchasing
ERR IAP: Purchase failed: error=Error Domain=SKErrorDomain Code=2 "iTunes Store" UserInfo={NSLocalizedDescription=iTunes Store}
ERR IAP: Purchase failed: error code=2
INFO IAP: OBJC initiatePurchase: 5hoursenglish subscription:false
WARNING IAP: Purchase purchasing
ERR IAP: Purchase failed: error=Error Domain=SKErrorDomain Code=2 "iTunes Store" UserInfo={NSLocalizedDescription=iTunes Store}
ERR IAP: Purchase failed: error code=2
INFO IAP: OBJC initiatePurchase: 5hoursenglish subscription:false
WARNING IAP: Purchase purchasing
INFO IAP: OBJC initiatePurchase: 5hoursenglish subscription:false
WARNING IAP: Purchase purchasing
INFO IAP: OBJC initiatePurchase: 5hoursenglish subscription:false
WARNING IAP: Purchase purchasing
ERR IAP: Purchase failed: error=Error Domain=SKErrorDomain Code=2 "iTunes Store" UserInfo={NSLocalizedDescription=iTunes Store}
ERR IAP: Purchase failed: error code=2
INFO IAP: OBJC initiatePurchase: 5hoursenglish subscription:false
WARNING IAP: Purchase purchasing

I had trouble using the in-line image upload to post the user's screenshot, so here is a link to an Imgur upload: http://imgur.com/a/pz5g5

You get that warning when the app fails to call finishTransaction on a transaction. When a user makes a purchase but their credit card information is expired they are asked to update the information then they may complete the purchase. If they do that then the app is sent a failed transaction followed immediately by a completed transaction. If your code can't handle that you wind up where you are. Is that possible?

Thanks for the insight.


Based on what you said, I _think_ I'm handling your scenario correctly -- for one thing, I didn't see any successive completed transactions following the failed transactions that showed up in my logs. As feedback, the "Cannot connect to iTunes store" error message with code 2 is very vague and even occurs whenever the user cancels the transaction without inputting their credit card information -- a clearer, more descriptive error message from Apple would be a bit more helpful.


We would've also seen a "Purchase purchased" or a "Purchase restored" in our logs had this scenario occurred -- which means that there wasn't a transaction with transactionState set to SKPaymentTransactionStatePurchased or SKPaymentTransactionStateRestored. Sure enough, I also didn't see any calls in my logs to validatePurchase (which makes a REST API web call and THEN calls finishTransaction upon completion)

For now, I asked the user to try restarting her app -- the idea is that restarting the app will trigger a transaction restore via restoreCompletedTransactions at start as suggested by Apple (which based on Apple's documentation, SHOULD "replay" the transactions in my backend, including the ones that weren't finished).


Currently, I implement the SKPaymentTransactionObserver protocol method updatedTransactions as suggested in the Apple Developer In-App Purchase Programming Guide as follows -- I believe it follows the documentation's recommendations, because I am also calling finishTransaction only if transactionState set to SKPaymentTransactionStatePurchased or SKPaymentTransactionStateRestored (EDIT: or SKPaymentTransactionStateFailed, as you can see in my code), and I am handling all of the same cases for transactionState as the suggested reference implementation:


If you have any ideas or spot anything suspicious in our implementation, please just let me know so I can take a look! Having payment issues is a sure-fire way to frustrate our users.



- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transactions
{
    for (SKPaymentTransaction *transaction in transactions) {
        switch (transaction.transactionState) {
            case SKPaymentTransactionStatePurchasing:
            {
                RLOGW(@"IAP: Purchase purchasing");
                // if we're in the game, hide the buy dialog? -- probably not necessary -- we already hide it
                break;
            }
            case SKPaymentTransactionStateDeferred:
            {
                RLOGW(@"IAP: Purchase deferred");
                // if we're in the game, hide the buy dialog? -- probably not necessary -- we already hide it
                break;
            }
            case SKPaymentTransactionStateFailed:
            {
                RLOGE(@"IAP: Purchase failed: error=%@", transaction.error);
                RLOGE(@"IAP: Purchase failed: error code=%ld", (long)transaction.error.code);

                RLOGI(@"IAP: will finishTransaction");
                [[SKPaymentQueue defaultQueue] finishTransaction:transaction];
        
                if (transaction.error.code == SKErrorPaymentCancelled) {
                    // user has cancelled

                    // invoke the purchase failed callback
                    if (self.gameUIRef != nil) {
                        [self.gameUIRef OnPurchaseCancelledCallback];
                    }

                    [self sendTransactionAnalyticsEvent:@"purchase cancelled" transaction:transaction];
                } else if (transaction.error.code == SKErrorPaymentNotAllowed) {
                    // payment not allowed

                    /* // show an alert dialog
                    UIAlertView *alert = [[UIAlertView alloc] initWithTitle:@"Purchase not allowed"
                                                                    message:@"You were not charged."
                                                                   delegate:nil
                                                          cancelButtonTitle:@"OK"
                                                          otherButtonTitles:nil];
                    [alert show]; */

                    // invoke the purchase cancelled callback
                    if (self.gameUIRef != nil) {
                        [self.gameUIRef OnPurchaseCancelledCallback];
                    }

                    [self sendTransactionAnalyticsEvent:@"purchase payment not allowed" transaction:transaction];
                } else {
                    // real error

                    // show an alert dialog
                    UIAlertView *alert = [[UIAlertView alloc] initWithTitle:@"Purchase failed"
                                                                    message:@"You were not charged."
                                                                   delegate:nil
                                                          cancelButtonTitle:@"OK"
                                                          otherButtonTitles:nil];
                    [alert show];

                    // invoke the purchase failed callback
                    if (self.gameUIRef != nil) {
                        [self.gameUIRef OnPurchaseFailedCallback];
                    }

                    [self sendTransactionAnalyticsEvent:@"purchase failed" transaction:transaction];
                }
                break;        
            }
            case SKPaymentTransactionStatePurchased:
            {
                RLOGI(@"IAP: Purchase purchased");

                // validate
                [self validatePurchase:transaction];

                break;
            }
            case SKPaymentTransactionStateRestored:
            {
                // I don't think we need to do anything here because we store transactions server-side
                RLOGW(@"IAP: Purchase restored");

                // might need to verify the ORIGINAL transaction (it should be idempotent on the backend, after all)
                // SUCCESS in this case would be either a duplicate result or a real success (as in "didn't go through before")

                // re-validate, did not go through -- this should only happen for consumables, right???
                // might just want to "finish" it??? so that people don't end up with freebies
                [self validatePurchase:transaction];

                break;
            }
            default:
                // For debugging
                RLOGE(@"IAP: Unexpected transaction state %@", @(transaction.transactionState));
                break;
        }
    }
}

By delaying the call to finishTransaction until the app receives a call back from your server you open yourself up to unfinished transactions. You need to be sure you have an active transaction observer to receive that transaction the next time the app enters foreground. You state you don't finishTransaction for a failed transaction - but you do in the code above. Which is it?

Hi, PBK!


Looks like you took some time to really look things over for me, thanks a bunch!
> You state you don't finishTransaction for a failed transaction - but you do in the code above. Which is it?


Sorry, to answer your question, the code here is more accurate than my prose was (when I wrote my post yesterday, I mistakenly implied that I didn't finishTransaction for a failed transaction -- I, in fact, do), when I wrote the code, I was actually following the documentation:


"Your app needs to finish every transaction, regardless of whether the transaction succeeded or failed."


Good catch, I have been in agreement with you here all along! I'll try to update my post to more accurate reflect what my code is doing. 🙂

> By delaying the call to finishTransaction until the app receives a call back from your server you open yourself up to unfinished transactions.


Right, that's true, and I implemented this intentionally aware of this possible outcome because the documentation explicitly states that "after you finish a transaction, don’t take any actions on that transaction or do any work to deliver the product. If any work remains, your app isn’t ready to finish the transaction yet."

The documentation then proceeds to list several very failure prone steps as examples:


"Complete all of the following actions before you finish the transaction:"

  • Persist the purchase.
  • Download associated content.
  • Update your app’s UI to let the user access the product.


and mentions that the "unfinished transactions remain in the queue until they’re finished, and the transaction queue observer is called every time your app is launched so your app can finish the transactions" (good!!! but why didn't this user's transaction get "restored" when she restarted her app even though the observer was called?).


Pretty clear guidance supporting my current approach, wouldn't you say?


It also places a lot of responsibility on the service-side for Apple to ensure that EVERY transaction that the user completes in the billing system somehow ends up persisted on the queue for future finishing/restoration -- if I were working on the IAP service for Apple, I would not feel comfortable making that kind of a service-level guarantee. We all know how fragile software can be! 😁


> You need to be sure you have an active transaction observer to receive that transaction the next time the app enters foreground.


Okay, yes, I do not necessarily have an active transaction observer whenever the app enters foreground -- persisting the purchase entails having the user "logged in" and authenticated on my app (so that I know which user account on my backend to credit the purchase to). This doesn't happen until a bit of time after the app enters foreground (but still nearly always happens).


In this case, I do have logs showing that this specific user subsequently restarted the app and her purchases were restored several times afterwards (but the one that ostensibly "already purchased" still doesn't attempt to get restored?).


So I'm not sure... perhaps I should also forcibly finish all of them in my release build as well (I started doing this for debug mode under advice from some StackOverflow post a long time ago, but I was under the impression that restoring would trigger the SKPaymentTransactionObserver protocol method updatedTransactions again, at least that's what I've seen happen time and time again while testing -- AND I do not want incomplete transactions to finish preemptively so that they will still be restored again sometime in the future -- maybe the user's Internet is down, etc. -- this would create a support and bad one-star app review nightmare for us)?


If you have any additional advice for me, that would be very helpful!


- (void)paymentQueueRestoreCompletedTransactionsFinished:(SKPaymentQueue*)queue
{
    RLOGI(@"IAP: paymentQueueRestoreCompletedTransactionsFinished");

#if DEBUG
    for (SKPaymentTransaction *transaction in queue.transactions) {
        RLOGI(@"IAP: will finishTransaction");
        [[SKPaymentQueue defaultQueue] finishTransaction:transaction];
    }
#endif
}

In case you don't believe me (unfortunately, we don't see any updatedTransactions logging messages triggered from the other code snippet above interleaved, as we might have expected):


Oct 04 08:31:18 <REDACTED USER EMAIL> LingolandNative: INFO IAP: paymentQueueRestoreCompletedTransactionsFinished
Oct 04 08:32:38 <REDACTED USER EMAIL> LingolandNative: INFO IAP: paymentQueueRestoreCompletedTransactionsFinished
Oct 04 08:36:44 <REDACTED USER EMAIL> LingolandNative: INFO IAP: paymentQueueRestoreCompletedTransactionsFinished
Oct 04 08:53:38 <REDACTED USER EMAIL> LingolandNative: INFO IAP: paymentQueueRestoreCompletedTransactionsFinished
Oct 04 08:55:40 <REDACTED USER EMAIL> LingolandNative: INFO IAP: paymentQueueRestoreCompletedTransactionsFinished
Oct 04 09:09:56 <REDACTED USER EMAIL> LingolandNative: INFO IAP: paymentQueueRestoreCompletedTransactionsFinished
Oct 04 09:12:36 <REDACTED USER EMAIL> LingolandNative: INFO IAP: paymentQueueRestoreCompletedTransactionsFinished
Oct 04 09:33:43 <REDACTED USER EMAIL> LingolandNative: INFO IAP: paymentQueueRestoreCompletedTransactionsFinished
Oct 04 09:36:49 <REDACTED USER EMAIL> LingolandNative: INFO IAP: paymentQueueRestoreCompletedTransactionsFinished
Oct 04 09:37:29 <REDACTED USER EMAIL> LingolandNative: INFO IAP: paymentQueueRestoreCompletedTransactionsFinished
Oct 04 09:38:29 <REDACTED USER EMAIL> LingolandNative: INFO IAP: paymentQueueRestoreCompletedTransactionsFinished
Oct 04 10:16:00 <REDACTED USER EMAIL> LingolandNative: INFO IAP: paymentQueueRestoreCompletedTransactionsFinished
Oct 04 10:22:48 <REDACTED USER EMAIL> LingolandNative: INFO IAP: paymentQueueRestoreCompletedTransactionsFinished
Oct 04 10:30:34 <REDACTED USER EMAIL> LingolandNative: INFO IAP: paymentQueueRestoreCompletedTransactionsFinished
Oct 04 10:32:42 <REDACTED USER EMAIL> LingolandNative: INFO IAP: paymentQueueRestoreCompletedTransactionsFinished
Oct 04 10:34:16 <REDACTED USER EMAIL> LingolandNative: INFO IAP: paymentQueueRestoreCompletedTransactionsFinished
Oct 04 10:45:33 <REDACTED USER EMAIL> LingolandNative: INFO IAP: paymentQueueRestoreCompletedTransactionsFinished
Oct 04 10:54:57 <REDACTED USER EMAIL> LingolandNative: INFO IAP: paymentQueueRestoreCompletedTransactionsFinished
Oct 04 16:45:52 <REDACTED USER EMAIL> LingolandNative: INFO IAP: paymentQueueRestoreCompletedTransactionsFinished
Oct 04 19:13:15 <REDACTED USER EMAIL> LingolandNative: INFO IAP: paymentQueueRestoreCompletedTransactionsFinished
Oct 04 20:55:16 <REDACTED USER EMAIL> LingolandNative: INFO IAP: paymentQueueRestoreCompletedTransactionsFinished
Oct 04 21:47:47 <REDACTED USER EMAIL> LingolandNative: INFO IAP: paymentQueueRestoreCompletedTransactionsFinished
Oct 04 21:54:42 <REDACTED USER EMAIL> LingolandNative: INFO IAP: paymentQueueRestoreCompletedTransactionsFinished
Oct 04 22:05:45 <REDACTED USER EMAIL> LingolandNative: INFO IAP: paymentQueueRestoreCompletedTransactionsFinished
Oct 04 22:40:06 <REDACTED USER EMAIL> LingolandNative: INFO IAP: paymentQueueRestoreCompletedTransactionsFinished
Oct 05 02:06:36 <REDACTED USER EMAIL> LingolandNative: INFO IAP: paymentQueueRestoreCompletedTransactionsFinished
Oct 05 02:36:25 <REDACTED USER EMAIL> LingolandNative: INFO IAP: paymentQueueRestoreCompletedTransactionsFinished
Oct 05 02:59:58 <REDACTED USER EMAIL> LingolandNative: INFO IAP: paymentQueueRestoreCompletedTransactionsFinished
Oct 05 04:02:40 <REDACTED USER EMAIL> LingolandNative: INFO IAP: paymentQueueRestoreCompletedTransactionsFinished
Oct 05 04:03:15 <REDACTED USER EMAIL> LingolandNative: INFO IAP: paymentQueueRestoreCompletedTransactionsFinished
Oct 05 04:58:29 <REDACTED USER EMAIL> LingolandNative: INFO IAP: paymentQueueRestoreCompletedTransactionsFinished
Oct 05 05:22:01 <REDACTED USER EMAIL> LingolandNative: INFO IAP: paymentQueueRestoreCompletedTransactionsFinished
Oct 05 06:00:24 <REDACTED USER EMAIL> LingolandNative: INFO IAP: paymentQueueRestoreCompletedTransactionsFinished
Oct 05 07:57:17 <REDACTED USER EMAIL> LingolandNative: INFO IAP: paymentQueueRestoreCompletedTransactionsFinished
Oct 05 08:02:04 <REDACTED USER EMAIL> LingolandNative: INFO IAP: paymentQueueRestoreCompletedTransactionsFinished
Oct 05 08:03:18 <REDACTED USER EMAIL> LingolandNative: INFO IAP: paymentQueueRestoreCompletedTransactionsFinished
Oct 05 08:54:56 <REDACTED USER EMAIL> LingolandNative: INFO IAP: paymentQueueRestoreCompletedTransactionsFinished
Accepted Answer

TMI.


>Pretty clear guidance supporting my current approach, wouldn't you say?


Yes, Apple says don't call finishTransaction until you have completed the transaction. I disagree with that advice. The worst thing to have is a set of unfinished transactions (search "the endless loop"). It is quite easy for the app itself to take over the responsibility for completing a transaction rather than relying on Apple's queue. (With the exception of downloading content stored by Apple.)

Consumable product "already purchased"?
 
 
Q