j3k0 / cordova-plugin-purchase

In-App Purchase for Cordova on iOS, Android and Windows
https://purchase.cordova.fovea.cc
1.29k stars 529 forks source link

[IOS] Purchase failing when prompted to update App Store payment info #631

Closed jonathankau closed 5 years ago

jonathankau commented 6 years ago

It appears that when a user is prompted to update their App Store payment information before purchasing, a transaction will move to SKPaymentTransactionStateFailed before receiving SKPaymentTransactionStatePurchased once their information is updated.

The default behavior of this plugin is to automatically call finishTransaction as soon as it sees SKPaymentTransactionStateFailed (https://github.com/j3k0/cordova-plugin-purchase/blob/master/src/ios/InAppPurchase.m#L458). I believe this is causing the plugin to treat these types of purchases as "Cancelled" even though they actually go through to Apple.

I'm not too familiar with iOS native code development, but my guess is that the plugin should no longer automatically finish transactions that receive SKPaymentTransactionStateFailed. Instead, the plugin needs to be able to enter a waiting state in case it hits SKPaymentTransactionStatePurchased.

Steps to Reproduce:

  1. Set up app store payment information in such a way that the user is asked to update/verify payment information during the purchase process (expired cc, no available credit card, etc).
  2. Attempt to purchase an auto-renewing subscription.
  3. Update payment information.
  4. Complete the purchase.

We have seen many of our users hit this flow and have verified with several people that had payment issues. The common thread was that they had to update their payment information. Here's a couple quotes from our users:

I went to pay through apple through the App Store but I had an old card on my account there but it gave me the option to pay with paypal so I did that.

I had put a new card in because it’s been awhile since I’ve purchased anything from iTunes and my card was replaced.

When I clicked to the button, I was sent to Apple store. I had a pre-inserted card but I decided to pay with paypal.

This causes the purchase to go through to Apple, but we never receive a receipt and the plugin indicates that the purchase was canceled.

Relevant links:

j3k0 commented 6 years ago

By default, the plugin doesn't auto finish transactions except if you set store.autoFinishTransactions to true.

See https://github.com/j3k0/cordova-plugin-purchase/blob/34cb5815f85bfdd29e7121856dc89fb17c7853e8/src/js/platforms/ios-adapter.js#L163

See the note about it: https://github.com/j3k0/cordova-plugin-purchase/blob/master/doc/api.md#random-tips

Are you sure you've not been using the autoFinishTransactions option while in dev and forgot to disable it?

Sorry, I do not have much time to dig in more, so just checking this first could save some pain ;-)

jonathankau commented 6 years ago

@j3k0 Thanks for the quick response!

We actually have never touched the autoFinishTransactions option. Looking at the implementation here https://github.com/j3k0/cordova-plugin-purchase/blob/master/src/ios/InAppPurchase.m#L459, the code seems to finish failed transactions when auto finish is off. Since auto finish is off by default, this seems to be the default behavior, right?

// Finish failed transactions, when autoFinish is off
if (!g_autoFinishEnabled) {
  [[SKPaymentQueue defaultQueue] finishTransaction:transaction];
  [self transactionFinished:transaction];
}

If the default behavior is to finish failed transactions, then that means that a transaction that sees SKPaymentTransactionStateFailed in the case described will be finished, right?

jonathankau commented 6 years ago

Diving a little deeper into this post on the Apple forums (https://forums.developer.apple.com/thread/6431):

When the app moves to the foreground, the queued transaction results are passed to the updatedTransactions delegate method. For many apps, this means seeing first the queued SKPaymentTransactionStateFailed result, followed by a second SKPaymentTransactionStatePurchased result.

The proper way to handle this sequence of events is to make sure to call finishTransaction for the SKPaymentTransactionStateFailed state, then process the SKPaymentTransactionStatePurchased state as you normally would in the app. This might mean validating the receipt or downloading hosted content. Make sure that there are no other actions to be taken before the application responds to the successful transaction with the finishTransaction result.

Perhaps the issue is instead with the state machine that the plugin relies on? This seems to indicate that finishing failed transactions is still the right approach, but we need to be able to handle future SKPaymentTransactionStatePurchased responses that may come in later.

j3k0 commented 6 years ago

If you look down to line 503 in the same function, failed transactions are always finished:

        else if (g_autoFinishEnabled && canFinish) {
            [[SKPaymentQueue defaultQueue] finishTransaction:transaction];
            [self transactionFinished:transaction];
        }

The if (!g_autoFinishTransaction) in the code you mentioned (the case SKPaymentTransactionStateFailed:) is here to prevents double finish (it could be refactored to be clearer I admit). So my question about you using this or not was irrelevant.

So, indeed SKPaymentTransactionStateFailed will finish the transaction in all cases. However, I wonder if the SKPaymentTransactionStatePurchased call that is subsequent contains the exact same transaction identifier or a new one. In the first case, I tried to dig for what kind of problem this could create but can't find anything: as far as I understand the code, the call to finish doesn't trigger any kind of state changes (it's not even bubbled-up to the JS side). But it's easy to make sure of that; if you can reproduce the issue on a device that's connected to a mac, make sure you added store.verbosity = store.DEBUG and run from XCode, all minor state changes will appear. You can copy-paste the logs here and I'll take a look.

I can try to guide you if you're willing to dig this up, I am pretty busy for a few weeks because I'm between a lot of deliveries and the start of new projects, but I'll try to respond as quickly as possible.

Note, realizing that JS side autoFinishTransactions and Obj-C side's g_autoFinishTransactions didn't have the same default at startup (but this was invisible as JS side will change Obj-C side at initialization). Anyway, to be consistent, I fixed this with fa40179.

jonathankau commented 6 years ago

However, I wonder if the SKPaymentTransactionStatePurchased call that is subsequent contains the exact same transaction identifier or a new one.

Great question. I'm not completely certain, but my guess from reading the Apple forums is that it uses a new transaction identifier. Off the top of your head, do you know how the plugin would handle might handle a subsequent SKPaymentTransactionStatePurchased call? We log every product state change in production, and all we're seeing is that product gets cancelled then it goes back to valid. We never seem to get an additional approved callback from the plugin.

j3k0 commented 6 years ago

All SKPaymentTransactionStatePurchased calls will bubble up to the storekitPurchased method that handles emitting approved. https://github.com/j3k0/cordova-plugin-purchase/blob/fa40179236e32146e9026688ab1e7bd34f8768f2/src/js/platforms/ios-adapter.js#L323

Only 2 things can prevent approved to be triggered.

  1. the product is not known (i.e. it hasn't been registered with store.register)
  2. the given transaction has already been approved (approved transaction ids are pushed into the product.transactions array, if the transaction is already in it we return from the function).
jonathankau commented 6 years ago

I tried to replicate the behavior, but some of the past transactions/receipts on this iPhone seems to have made the logging confusing. I added some comments as to what I think happened though. Do you know if it's possible to clear out Apple's cache for debugging purposes? Will see if I can try again on a clean iPhone.

Oct  3 14:28:59 InAppPurchase[objc]: About to do IAP
Oct  3 14:28:59 InAppPurchase[objc]: Transaction updated: com.pennyapp.subscription_4
Oct  3 14:28:59 InAppPurchase[objc]: Purchasing...
Oct  3 14:28:59 InAppPurchase[objc]: State: PaymentTransactionStatePurchasing
Oct  3 14:29:25 InAppPurchase[objc]: Transaction updated: com.pennyapp.subscription_4
Oct  3 14:29:25 InAppPurchase[objc]: Error ERR_PAYMENT_CANCELLED - Cannot connect to iTunes Store
Oct  3 14:29:25 InAppPurchase[objc]: State: PaymentTransactionStateFailed

// At the same time that the transaction fails, Apple brings you out of your app and
// into iTunes to update your billing info. If you update your billing info successfully,
// then a confirming modal pops up, but you don't get sent back to your app automatically.

// Returned to the app, but nothing had changed/triggered.
// This seems to suggest that we don't even receive the subsequent state update to
// `PaymentTransactionStatePurchased`.

// Closed and reopened the app.

// These all seemed to happen upon our initial `store.refresh` at app open. I believe
// these are due to past subscription transactions on this device.
Oct  3 14:31:58 InAppPurchase[objc]: Transaction updated: com.pennyapp.subscription_4
Oct  3 14:31:58 InAppPurchase[objc]: State: PaymentTransactionStatePurchased
Oct  3 14:32:00 InAppPurchase[objc]: Transaction <A> finished.
Oct  3 14:32:06 InAppPurchase[objc]: Request to refresh app receipt
Oct  3 14:32:06 InAppPurchase[objc]: Starting receipt refresh request...
Oct  3 14:32:06 InAppPurchase[objc]: Receipt refresh request started
Oct  3 14:32:08 InAppPurchase[objc]: Transaction updated: com.pennyapp.subscription_5
Oct  3 14:32:08 InAppPurchase[objc]: State: PaymentTransactionStateRestored
Oct  3 14:32:08 InAppPurchase[objc]: Transaction updated: com.pennyapp.subscription_4
Oct  3 14:32:08 InAppPurchase[objc]: State: PaymentTransactionStateRestored
Oct  3 14:32:10 InAppPurchase[objc]: Transaction <B> finished.
Oct  3 14:32:10 InAppPurchase[objc]: Transaction <C> finished.

// Tried to subscribe again/restore the purchase, got the classic
// "You've already bought this..." modal.

Oct  3 14:32:18 InAppPurchase[objc]: About to do IAP
Oct  3 14:32:18 InAppPurchase[objc]: Transaction updated: com.pennyapp.subscription_4
Oct  3 14:32:18 InAppPurchase[objc]: Purchasing...
Oct  3 14:32:18 InAppPurchase[objc]: State: PaymentTransactionStatePurchasing
Oct  3 14:32:19 InAppPurchase[objc]: Transaction updated: com.pennyapp.subscription_4
Oct  3 14:32:19 InAppPurchase[objc]: State: PaymentTransactionStatePurchased
Oct  3 14:32:20 InAppPurchase[objc]: Transaction <D> finished.

// ^This behavior actually doesn't even happen with our users who encounter the same issue.
// In their cases, we always immediately get a 'Cancelled' event from the plugin along with the
// "You've already bought this..." modal.

// I believe the discrepancy is because this iPhone already had a prior receipt, so the new
// purchase updated the old one.

Oct  3 14:32:43 THREAD WARNING: ['InAppPurchase'] took '20.123047' ms. Plugin should use a background thread.
Oct  3 14:32:43 InAppPurchase[objc]: Getting products data
j3k0 commented 6 years ago

For the very purpose of cleaning up the transactions on a device you can set store. autoFinishTransactions = true; at initialization (before the initial refresh) then launch the app. Wait a few minutes. Exit and uninstall the app. Then log out from iTunes in your app settings, create a new Test User in iTunesConnect...

Then you can start the app again (don't forget to disable autoFinishTransactions), login with the Test User from your app (compiled for debug, not release).

From the logs above, seems like the Obj-C side doesn't receive any event. I wonder if it's possible that the transaction is reported before the transaction observer is added.

j3k0 commented 6 years ago

Hello, would you like to try with the branch linked bellow? It implemented the above-mentionned fixes.

https://github.com/j3k0/cordova-plugin-purchase/tree/fix/ios-early-observer

It's roughly tested, seems alright.

jonathankau commented 6 years ago

@j3k0 Thanks for that. Unfortunately, I haven't figured out a way to reproduce this behavior while in debug. This is because when you test IAP's in sandbox mode, Apple doesn't actually check whether the sandbox user has a valid payment method attached.

It looks like it's possible to build an IPA for release and then install it via iTunes, so I might try that approach to test out the early observer branch you wrote. I'll also try and grab logs of this edge case for an iPhone that hasn't had any IAP's with our apps before.

jonathankau commented 6 years ago

@j3k0 Still haven't been able to figure out how to properly test a new iOS release build pointing at Apple's production IAP system. I'm not even sure if it's possible. Any ideas on that front?

The reason why I need to test using Apple's production IAP system is because reproducing the issue starts with removing all of your payment methods from your Apple ID account. In Sandbox, Apple just automatically approves all IAP purchases.

At the very least, after investigating some more, I believe you're on the right track with the early observer solution. First, I just want to walk you through some screenshots so that we're on the same page.

1) To start out, I remove my credit card from my Apple account via iTunes. After initiating a purchase, you get a modal to Confirm. One you confirm, this modal pops up saying you need to update your payment info. img_0002

2) After tapping on Continue, you get sent to the App Store to update your billing info. img_0003

3) Once you confirm your billing info, you get brought back to the homepage of the App Store. There, a new modal shows up to confirm your Apple account password. img_0004

4) After confirming your password, you get a "Purchase Successful" modal on the App Store page. Returning to your app does not trigger anything.

jonathankau commented 6 years ago

I believe you're on the right track with the early observer because of this issue I found - https://github.com/infinitered/ProMotion-iap/issues/17. It references Apple documentation that says the following:

StoreKit automatically notifies your observer when the content of the payment queue changes upon resuming or while running your app. Adding your app's observer at launch ensures that it will persist during all launches of your app, thus allowing your app to receive all the payment queue notifications.

When the app resumes, it has no observers as the above observer was deallocated when your app was sent to the background. As a result, your app would not get notified about the transaction in the queue.

My guess is that the observer is deallocated upon getting sent to the App Store. Thus, when you complete the transaction on the main page of the App Store, the app itself has no way of receiving the SKPaymentTransactionStatePurchased event. Does that make any sense?

chrissterling commented 6 years ago

I think we've got the same issue with some auto-renewing subscriptions failing, even though the payment goes through to Apple we get no notification on the server (most of the time is all fine). Our customers say there was no error and the money is taken, but when they look the subscription status shows as Pending (that never changes). We have no log at all our side.

Any help much appreciated - and whatever I can do as well just let me know.

stale[bot] commented 6 years ago

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

j3k0 commented 5 years ago

This needs to be tested. I'd like to be sure this is fixed now.

j3k0 commented 5 years ago

No more reports for about 6 months, let's close.