j3k0 / cordova-plugin-purchase

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

[ANDROID] Expected Flow for subscription purchase and init app #777

Closed lfreneda closed 5 years ago

lfreneda commented 5 years ago

Hello there.

Just read about in-app purchase flow in others issues, but this question still confusing to me.

The approved event is triggering every time I start my app, is this an expected behavior? Should I validate the transaction in every app start?

I'm responsible for checking subscriptions expiration time?

Is there any sequence diagram to illustrate better this cycle of events?

Thanks

j3k0 commented 5 years ago

approved event is triggering every time I start my app

It is an expected behaviour. You should validate the transaction at every app start : it might have been cancelled or refunded.

checking subscriptions expiration time

The server should do it, you have to integrate with Apple/Google APIs to get the transaction status and expiry date. Are you using your own validator?

lfreneda commented 5 years ago

Hello @j3k0, Thanks for your answer!

Let me recap:

It is an expected behaviour. You should validate the transaction at every app start : it might have been cancelled or refunded.

Every app start I should validate given transaction on my server, right?

I'm using my own validator written in node.js with googleapis package ( https://github.com/googleapis/google-api-nodejs-client )

Given a validated transaction, I should keep that user as premium

Given an invalidated transaction (canceled or refunded) I should set that user as free (non-paying customer)

This look something like this:

let product = req.body

let validation = await gPlaySubscriptionValidator({
    productId: product.id,
    purchaseToken: product.transaction.purchaseToken
})

if (validation.isValid && validation.autoRenewing === true) {
    await updateUser(req.user.uid, {
        planType: 'premium'
    })
    res.status(200).json({
        ok: true,
        data: product
    })
} else {

    await updateUser(req.user.uid, {
        planType: 'free'
    })
    res.status(200).json({
        ok: false,
        code: 6778003, //store.PURCHASE_EXPIRED
        error: {
            message: 'subscription has expired or canceled :('
        }
    })
}

Is that right?

I also notice that after a few days a given subscription been canceled, the product.transaction object is gone, so the plugin will not emit approved event

In this scenario How should I validate that these users are not premium anymore?

Thanks (again) for your time and help :+1:

rafaellop commented 5 years ago

I also have some issues with the subscriptions. I'm releasing my app and doing last tests before release and shockingly observed some strange behavior (Android) and maybe it's related to this issue. I noticed the issue when testing subscriptions. I use the correct flow for subscriptions which is like mentioned here https://github.com/j3k0/cordova-plugin-purchase/issues/731#issuecomment-429803684 by @j3k0 and the product.owned is true BUT ONLY for the initial purchase. If I restart the app I can see that for the updated event all products are not owned. Here's a debug for a subscription product:

[store.js] DEBUG: store.queries !! 'sub_month.1 loaded'
store-android.js:1513 [store.js] DEBUG: store.queries !! 'paid subscription loaded'
store-android.js:1513 [store.js] DEBUG: store.queries !! 'subscription loaded'
store-android.js:1513 [store.js] DEBUG: store.queries !! 'valid loaded'
store-android.js:1513 [store.js] DEBUG: store.queries !! 'loaded'
store-android.js:1513 [store.js] DEBUG: store.queries !! 'sub_month.1 updated'
store-android.js:1513 [store.js] DEBUG: store.queries !! 'paid subscription updated'
store-android.js:1513 [store.js] DEBUG: store.queries !! 'subscription updated'
store-android.js:1513 [store.js] DEBUG: store.queries !! 'valid updated'
store-android.js:1513 [store.js] DEBUG: store.queries !! 'updated'
InAppBilling[js]: getPurchases called!

The above call update events where I output the product to debug it and it looks this way:

app_controller_inapp.js:871 product UPDATED {"id":"sub_month.1","alias":"sub_month.1","type":"paid subscription","state":"valid","title":"Monthly","description":"Monthly subscription","priceMicros":2000000,"price":"$2.00","currency":"USD","countryCode":null,"loaded":true,"canPurchase":true,"owned":false,"downloading":false,"downloaded":false,"additionalData":null,"transaction":null,"valid":true}

As you can see the owned is false and transaction is null. If I call store.refresh() by hand I get:

[store.js] DEBUG: store.trigger -> triggering action refreshed
store-android.js:1513 [store.js] DEBUG: queries !! 'refreshed'
store-android.js:1513 [store.js] DEBUG: refresh -> checking products state (1 product)
store-android.js:1513 [store.js] DEBUG: refresh -> product id sub_month.1 (sub_month.1)
store-android.js:1513 [store.js] DEBUG:            in state 'valid'
store-android.js:1513 [store.js] DEBUG: store.trigger -> triggering action re-refreshed
store-android.js:1513 [store.js] DEBUG: queries !! 're-refreshed'
store-android.js:2040 InAppBilling[js]: getPurchases called!

And no update events.

At the moment there's no way to confirm subscription of a product without additional in-app logic and using local storage which is insecure and what's a more serious issue is if a user incidentally clears the local storage for the app they cannot restore in-app purchase by any chance. Same if a user cancells the subscription the app cannot get any info about it.

When I try to purchase the product again it is possible and the plugin allows it, but Google gives error that is is already owned, but the state in the app is not refreshed to owned again.

Can somebody help with that? My device OS is Android 8.1.0. (minSDK 19).

rafaellop commented 5 years ago

Update: the above was probably due to the Google Play Services. I restarted my device and the plugin has started to remember the owned products and reports them at the init and from store.refresh(); So if you notice similar issues, try to restart your device, Google Play Service or clear its data. In my case restart helped.

However the other issue is still present. There's no event if the subscription is cancelled by the user. The plugin doesn't fire neither expired or any other event that could be helpful in that case and allow to detect subscription is over.

j3k0 commented 5 years ago

Cancelling a subscription won't trigger an event in real-time (as the native billing SDK don't notify the app in any ways). There's a server side "real-time notifications" API that you can implement if you need just-in-time subscription status.

The normal flow is that the subscription status will be checked next time the app is started. If your receipt validator is implemented properly, it will detect that the transaction has been cancelled and return the appropriate error code (so the client-app will know the user isn't subscribed anymore).

A good enhancement to the plugin's API would be to add a method to trigger a revalidation, so the app can forcefully check the subscription status before (for example) downloading some new content. Such API isn't implemented at the moment. I'll add it to the backlog.

stale[bot] commented 5 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.

dominic-simplan commented 5 years ago

@j3k0 So calling store.refresh() periodically is not a good way to check if the subscriptions are still valid?

j3k0 commented 5 years ago

@dominic-simplan Calling store.refresh() regularly can be a solution, on iOS this will generally open a dialog for the user to enter his/her password.

However, I don't think there's a real problem here. The default behavior is to refresh all statuses when the app starts, it is good enough since subscriptions are generally counted in days and not in hours/minutes. Apps don't stay backgrounded for hours, last time I checked, the average on iOS was about 10 minutes. You're pretty much guaranteed to refresh the status at least once every day.

dominic-simplan commented 5 years ago

@j3k0 okay, thanks! I saw the iOS dialog but I wasn't sure how it exactly works because of this comment, which says that the popup is only shown if two different accounts are used for iCloud and the AppStore.

Anyway, if the iOS apps are started anew at last once a day, this is fine for me. I think Android also removes the background apps regularly, however there is an option to exclude apps from the battery optimization. I don't know how this would affect the background cleaning. Only Windows, on the other side, you could leave the app open indefinitely if you only send your PC to sleep.

So probably I'll add a check for Android and UWP to call the refresh() method if it hasn't been called in the last 24 hours or so. An API for revalidation (as you've mentioned above) which can also be used on iOS would probably be nice in the long run :-)

j3k0 commented 5 years ago

Only Windows, on the other side, you could leave the app open indefinitely if you only send your PC to sleep.

Good point, didn't think of that.

I guess the best course of action would be that the plugin decide when to do a revalidation (so it's not something developer have to worry about, they just have to know that it's handled).

It's actually a very simple change since refresh() on Android and Windows do not trigger password popups. I can simply setInterval this call every 24 hours. On iOS, nothing will be done.

I'll make a PR right away. Let me know if you think there's a better approach.

stale[bot] commented 5 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.