Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

iOS Sandbox Subscription Renewal #62

Closed
nlogioco opened this issue Jun 10, 2020 · 21 comments
Closed

iOS Sandbox Subscription Renewal #62

nlogioco opened this issue Jun 10, 2020 · 21 comments

Comments

@nlogioco
Copy link

nlogioco commented Jun 10, 2020

This is a duplicate issue of #50 but no one responded.

Flutter 1.17.3 stable
Dart 2.8.4
purchases_flutter 1.1.1

Plan: build

When purchasing a subscription via sandbox, it'll renew 5 times and then auto cancel.

At this point, I'd like to renew the subscription and it fails here. I've tried using both purchase methods purchasePackage and purchaseProduct with no success.

I'd also like to mention that if I close the app and reopen it and try to renew every so often it'll work, but 90% of the time it won't.

@rkotzy
Copy link
Member

rkotzy commented Jun 10, 2020

Are you receiving an error message when trying to purchase? What is the failure you're experiencing?

@nlogioco
Copy link
Author

nlogioco commented Jun 11, 2020

@rkotzy I'm not receiving an error message.

When you do purchasePackage it returns a PurchaserInfo value with information in it, but the important part of it being that isActive is false.

I also found out i don't have to close and reopen the app. If I just keep clicking the purchase button, it'll eventually work.

@aboedo
Copy link
Member

aboedo commented Jun 11, 2020

I think this might be a caching issue - the SDK will cache the value of the purchaserInfo locally for 5 minutes. In practice, in production, 5 minutes is a significantly shorter time than an actual subscription duration, so this works as expected.
However, in sandbox, subscription durations are short, so there could be a short disconnect between the cached value and the real one.
I'm a bit puzzled by the fact that re-opening the app doesn't work 90% of the time.
Do you get the regular confirmation message from iOS saying that your purchase was successful?

@nlogioco
Copy link
Author

nlogioco commented Jun 12, 2020

@aboedo I think you may be on the right path around cache issue. Another issue I see is if my subscription expires and I check to see if it is active, regardless of time passed (i've tested with 1 minute passing and also 24 hours) the first time I check to see if my subscription is active it'll say true, but afterwards it'll say false.

This may be with how I'm making the calls, so i'll share that.

PurchaserInfo info = await Purchases.getPurchaserInfo();
bool isActive = info.entitlements
          .all[_Constants.kEntitlementMonthlySubscription]?.isActive;

I'd also like to point out I've also tried:

info.activeSubscriptions.isNotEmpty

Both return the same value.

One last note, after my subscription renew 5 times and then auto cancels, I have to click the purchase button 5 times and on the 6th click, it works. Coincidence between number of clicks and number on times a monthly subscription renews in sandbox?

Here's my purchase code incase you were wondering:

PurchaserInfo purchaserInfo = await Purchases.purchasePackage(package);

@nlogioco
Copy link
Author

nlogioco commented Jul 9, 2020

@aboedo Just wanted to follow up on this. I was hoping this issue was isolated to sandbox only, but apple actually rejected my app because of this issue.

I hope this can be fixed ASAP seeing as this is a show stopper for anyone trying to use your framework in their flutter app.

@aboedo
Copy link
Member

aboedo commented Jul 10, 2020

@nlogioco Could you share more details on the reason for the rejection? We haven't had issues with this before.

Another issue I see is if my subscription expires and I check to see if it is active, regardless of time passed (i've tested with 1 minute passing and also 24 hours) the first time I check to see if my subscription is active it'll say true, but afterwards it'll say false.

This is expected behavior - when you call purchaserInfo, you'll get a cached value first, and if the cache is outdated this will trigger a refresh, and purchaserInfo will be updated.
You can use addPurchaserInfoUpdateListener (source code here) to get notified when the purchaserInfo gets updated, and update your UI accordingly.

@nlogioco
Copy link
Author

@aboedo The reason for rejection was when trying to repurchase a subscription that has expired, it requires the user to tap on the purchase button the number of months they had an active subscription before expiration.

Just so I understand about the updateListener. When I check to see if I have an active subscription and it returns a value, that value can sometimes be incorrect due to the cached value? Shouldn't that cached value be checked to see if it is expired before it returns a value?

I'm running into two issues:

  1. Checking to see if an entitlement is active will always give me the wrong value once an auto renew has expired the first time I check it (I know there is a 5 minute cache, but even if 24 hours goes by it still returns the cached value.)

  2. If I cancel my subscription after 5 months. I have to click purchase 5 times (all 5 times give me the Purchase Successful: false log.) If I click purchase a 6th time, this one will trigger the normal iOS IAP system popup.

I dug into the iOS revenuecat pod code a little yesterday and for some reason, each of the 5 clicks has a transactionState of purchased even though I know for a fact it is expired.

@aboedo
Copy link
Member

aboedo commented Jul 10, 2020

Just so I understand about the updateListener. When I check to see if I have an active subscription and it returns a value, that value can sometimes be incorrect due to the cached value? Shouldn't that cached value be checked to see if it is expired before it returns a value?

the current implementation of the method (at a high level) does:

  • if there's a cached value:
    • if cache is outdated:
      • start updating the cache
    • return cached value for now

if the cached value is updated, it'll be notified through the listener.

The reason for this is that there's no guarantee that the cache will be updated at all, because there's no guarantee that the user has connectivity. If it does get updated, we don't know how long it'll take, and we want the API to be as responsive as possible.
One way to prevent issues coming from this is to fetch purchaserInfo early in the app flow, so that the value is updated early (if possible).

Checking to see if an entitlement is active will always give me the wrong value once an auto renew has expired the first time I check it (I know there is a 5 minute cache, but even if 24 hours goes by it still returns the cached value.)

This sounds like you're getting the cached value. You can use the listener to ensure that if the cache is outdated, you get notified as soon as it gets refreshed.

There should be no correlation (that I know of) between the number of times a subscription renews and the number of times initiating a purchase fails.

Could you share the logs for this? When you call purchase, the SDK sends the transaction to the queue, so if you're getting a failure it would likely be related to iOS rejecting the purchase, not RevenueCat.

In practice, it's very, very rare for a user to re-purchase a subscription within seconds of it expiring, particularly in production where subscriptions have a much longer duration. And this kind of edge case would only occur if the app is open while the subscription expires.

@nlogioco
Copy link
Author

nlogioco commented Jul 10, 2020

@aboedo

Thank you for the quick response. After digging a little more I believe there is a race condition happening in the iOS pod code (this is for issue 1.)

When you first open the app, it updates all cached values (and it sets the purchaserInfoCacheTimestampToNow.) BUT, if I call getPurchaserInfo before this returns a value, the cached value is returned (with the wrong values if days have gone by.)

I think moving the [self.deviceCache setPurchaserInfoCacheTimestampToNow]; inside of the completion block would prevent this from happening (it could cause other issues but I'm not familiar with your code.)

For an immediate fi for me, I'm essentially going to store a local purchaserInfo variable and if it doesn't have a value, I know that the listener hasn't fired with a new value.

This seems like an inelegant fix. I don't know if this will fix issue 2 though. Will test that out in a bit.

@nlogioco
Copy link
Author

@aboedo

Wanted to followup on my previous message. I started using just the listener in my app and waiting for that value.

If I quit the app and wait for my subscription to end and then reopen the app. The listener spits out two values (within a couple seconds.) The first value returned says my subscription is active. The second value returned says my subscription is not active.

I know when I set the listener, it'll give me the latest purchaser info as soon as it is set (which in this case means I'll receive the cached version first, some time will pass and then it'll give me the servers value.)

It would be horrible UX for me to allow them in the app because the cached value tells me their subscription is active and then a second or two later, the servers value comes through and now their subscription says it isn't active so they go back to some IAP screen.

I wish there was a value I could set on the listener, that would never return a cached value.

I'm thinking at this point, of removing all cached items every time the user opens the app. That way nothing is cached and will always hit the server.

@aboedo
Copy link
Member

aboedo commented Jul 10, 2020

@nlogioco thanks for the follow up.

The feedback is valid. We've been talking about ways of improving the API for this, and we'll probably include a parameter that will allow developers to elect whether or not they want the cached value depending of how long ago it was obtained. I don't have an ETA for it, though.

It would be horrible UX for me to allow them in the app because the cached value tells me their subscription is active and then a second or two later, the servers value comes through and now their subscription says it isn't active so they go back to some IAP screen.

This is actually fairly common, but one way to prevent it from happening would be to set things up so that the first time you get the value, you wait for a few seconds before updating the UI in case a new one comes in, and if it doesn't, use the first value you got and assume that it hasn't changed.

However, note that the case where you allow them in the app and then potentially kick them out if the subscription expired is unavoidable to a certain extent - if a user doesn't have connectivity, the app will have no choice but to assume that the cached value is correct, and if a new value comes in (say, a user gets out of the subway), update the UI and kick them out if the subscription expired. And on slow connections, you might not want to wait for a server value either if it means that a user is stuck looking at a spinner for 30 seconds.

I'd advise against removing all cached items the first time the user opens the app for that reason - there's no guarantee that a value will come in at all, and mobile connectivity is fickle. Also, manually removing cached items might end up causing data consistency issues.

@nlogioco
Copy link
Author

nlogioco commented Jul 11, 2020

@aboedo

Now the only issue I'm having is the purchase button not working after canceling a subscription.

*Again this is for some reason 100% failing x number of times, where x equals the number of subscribed months.

Here is the output from a purchase failing:

DEBUG: Vending offerings from cache
DEBUG: makePurchase
DEBUG: PaymentQueue updatedTransaction: ${productID} 1000000691595586 ((null)) 1000000687850152 - 1
DEBUG: Loaded receipt from file:///private/var/mobile/Containers/Data/Application/FF6B77C5-4394-454F-B4A3-10CD6288FD7F/StoreKit/sandboxReceipt
INFO: found 0 unsynced attributes for appUserID: ${appUserID}
DEBUG: POST /v1/receipts
DEBUG: POST /v1/receipts 200

DEBUG: Finishing ${productID} 1000000691595586 (1000000687850152)

DEBUG: PaymentQueue removedTransaction: ${productID} 1000000691595586 (1000000687850152 (null)) (null) - 1

When building native iOS apps, I remember on app start, I had to check for any updated transactions and finish them. I'm thinking this could be the issue since the number of times it fails correlates to the number of transactions.

I'm not setting setFinishTransactions to false, but it looks like whenever I try to purchase you can see it's trying to finish a previous purchase DEBUG: Finishing...

I think a possible solution would be when you call setup it checks all the offerings/products and see if they have any pending transactions that need to be finished.

@nlogioco
Copy link
Author

@aboedo

Just wanted to followup as this issue is a blocker

@rkotzy
Copy link
Member

rkotzy commented Jul 14, 2020

All of the caching behavior you're describing seems like the expected behavior. getPurchaserinfo() will always read from the cache (if available) then update the cache in the background if it's older than 5mins.

This means users can get one launch "for free" after a subscription expires since the cached value could be stale. The design decision here is that it's better to give a lapsed user temporary access then it is to prevent a paying user from accessing content because the network is flakey. We do have some backlog items to give developers more flexibility here out-of-the-box, since every app is different.

Now the only issue I'm having is the purchase button not working after canceling a subscription.

Can you provide the full debug logs here? Maybe there's something going on earlier in the setup that can help pinpoint the issue - we're unable to reproduce this on our side. One thing that can cause behavior like this is if multiple instances of "Purchases" are being configured unintentionally. You may be making the purchase on one instance, but reading the PurchaserInfo from another instance. Does the problem reproduce in the sample app?

@nlogioco
Copy link
Author

@rkotzy

After hours is debugging and trying to figure out what was going on, I decided to change basically 1 line of code.

What made this work for me, was calling setup immediately when the app was launched (vs when a user logs in) and calling identify when the user logs in.

I don't know why this fixed my issue (setup was only being called once) but it did. The way the documents made it sound was aliasing was for the most part optional, but if you have any sort of user identification it is required since you won't know the users identification until they sign up / login.

If you could give me a little clarity on why this worked? Again, all I changed was adding setup when app was launched, and identify when user logs in, vs calling setup when user logs in.

Thanks.

@rkotzy
Copy link
Member

rkotzy commented Jul 15, 2020

Hey @nlogioco glad that got it working!

It is recommended that you configure the SDK as early as possible in the application lifecycle since the SDK can't listen to the SKPaymentTransactionQueue until it's configured. This may explain why it seemed like purchase button wasn't working and you were seeing unfinished transactions on the queue - the purchase may have been initiating but there was no connection between the SDK and StoreKit to listen for or finish any transactions.

I'll see if there are some documentation updates we can make to hopefully make this more clear for future developers. Let me know if there was a particular part that was misleading and I'll see if we can clarify.

@lvlrSajjad
Copy link

lvlrSajjad commented Aug 1, 2020

@aboedo @rkotzy I have this problem too
image
When I try to buy a package It returns false for is pro I tried calling setup early and calling identify after authentication.
It works fine on Android.
But on ios something is wrong.
image
It shows unsubscribeDetectedAt just after buying.

@lvlrSajjad
Copy link

Ok today I tested it and it worked as expected I don't know what happened

@efraespada
Copy link

Same problem here. Using 1.3.1.

Revenue fails when trying to do subscription purchases when testing with Testflight. Every try with a new Sandbox account.

@ikicodedev
Copy link

ikicodedev commented Oct 24, 2020

Same problem as indicated by @lvlrSajjad with versión 1.4.0

@efraespada
Copy link

I recommend recording successful purchases progress while developing.

Randomly works, but only 1 of 10 tries (approximately).

These screen records (with successful purchases) can be used to review the new builds sent to the AppStore. Explain the case to the reviewers.

In production, it never fails 👌🏼

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

6 participants