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 check if autorenewal subscription is still active #275

Closed
marcosmartinez7 opened this issue Sep 27, 2018 · 64 comments
Closed

IOS check if autorenewal subscription is still active #275

marcosmartinez7 opened this issue Sep 27, 2018 · 64 comments
Labels
📱 iOS Related to iOS 🚶🏻 stale Stale

Comments

@marcosmartinez7
Copy link

marcosmartinez7 commented Sep 27, 2018

Version of react-native-iap

2.2.2

Platforms you faced the error (IOS or Android or both?)

IOS

Expected behavior

Using the RNIAP api we could check if a autorenewal subscription is still active

Actual behavior

On android im doing this to check that:

export const isUserSubscriptionActive = async (subscriptionId) =>{
    // Get all the items that the user has
    const availablePurchases = await getAvailablePurchases();
    if(availablePurchases !== null && availablePurchases.length > 0){
        const subscription = availablePurchases.find((element)=>{
            return subscriptionId === element.productId;
        });
        if(subscription){
             // check for the autoRenewingAndroid flag. If it is false the sub period is over
              return subscription["autoRenewingAndroid"] == true;
            }
        }else{
            return false;
        }
    }
}

On ios there is no flag to check that, and the getAvailablePurchases method returns all the purchases made, even the subscriptions that are not active at the moment.

Is there a way to check this?

Regards,
Marcos

@kevinEsherick
Copy link

kevinEsherick commented Sep 27, 2018

I'm working on figuring this out as well. I haven't tested it yet, but from what I'm reading I'm gathering that it's possible by creating a "shared secret" in iTunes Connect and passing this to validateReceiptIos with the key 'password'. I believe this will then return a JSON object which will contain a code indicating the validation status of the subscription and the keys latest_receipt, latest_receipt_info, and latest_expired_receipt info, among others, which you can use to determine the subscription status. I am literally just figuring this out so I have yet to test it. It's what I'm putting together from the issues and Apple's docs. If this works, it really should be made very clear in the documentation instead of being buried in the issues.
I believe the following links are relevant:
https://developer.apple.com/library/archive/releasenotes/General/ValidateAppStoreReceipt/Chapters/ValidateRemotely.html
#203 #237

EDIT: I can confirm that the process I mentioned above works. I think we should work to get a full explanation of this in the docs. I would implement something like this on app launch to determine whether a subscription has expired:

RNIap.getPurchaseHistory()
        .then(purchases => {
                RNIap.validateReceiptIos({
                   //Get receipt for the latest purchase
                    'receipt-data': purchases[purchases.length - 1].transactionReceipt,
                    'password': 'whateveryourpasswordis'
                }, __DEV__)
                    .then(receipt => {
                       //latest_receipt_info returns an array of objects each representing a renewal of the most 
                       //recently purchased item. Kinda confusing terminology
                        const renewalHistory = receipt.latest_receipt_info
                       //This returns the expiration date of the latest renewal of the latest purchase
                        const expiration = renewalHistory[renewalHistory.length - 1].expires_date_ms
                       //Boolean for whether it has expired. Can use in your app to enable/disable subscription
                        console.log(expiration > Date.now())
                    })
                    .catch(error => console.log(`Error`))
        })

@hyochan hyochan added 🙏 help wanted Extra attention is needed 📱 iOS Related to iOS labels Sep 28, 2018
@marcosmartinez7
Copy link
Author

marcosmartinez7 commented Oct 9, 2018

@kevinEsherick

Brilliant!

Just some things that arent so trivial about your code;

  1. It should be

const expiration = renewalHistory[renewalHistory.length - 1].expires_date_ms

instead of (probably just a typo)

const expiration = latestRenewalReceipt[latestRenewal.length - 1].expires_date_ms

  1. For those who dont know what is 'password': 'whateveryourpasswordis':

You can create your shared secret key for your In App paments and use it on your app

Steps are here: https://www.appypie.com/faqs/how-can-i-get-shared-secret-key-for-in-app-purchase

+1 to update the docs, also, maybe @dooboolab you can separate the use cases in IOS and Android seems there are a few things different. I can help you if you want to.

Regards

@marcosmartinez7
Copy link
Author

marcosmartinez7 commented Oct 9, 2018

@kevinEsherick

What happens with autorenewal subscriptions?

Seems that the expires_date_ms is the first expiration date, it is not updated

I have tested this:

  1. Subscribe to a subscription item
  2. Check the expiration date (like 5 minutes after)
  3. Wait 10 minutes
  4. Subscription still active seems is autorenewal subscription
  5. If i check the last renewal receipt expiration is still the same as point 2

Any ideas?

My bad, the expiration date seems to be updated, it just takes a while.

I will leave this comment here in case anyone is in the same scenario.

Regards

@kevinEsherick
Copy link

  1. It should be

const expiration = renewalHistory[renewalHistory.length - 1].expires_date_ms

instead of (probably just a typo)

const expiration = latestRenewalReceipt[latestRenewal.length - 1].expires_date_ms

Oops yea you are correct, that was a typo. I had changed the name to make its meaning more obvious but forgot to change all occurrences. I've edited it, thanks.

And I haven't had that exact scenario with expiration date but there was some stuff with it not autorenewing at all, though it somehow resolved on its own (which worries me, but I can't reproduce so idk what else to do). The issue you had is a fairly common one. It's expected behavior on Apple's end, so you should leave a buffer period for the subscription to renew before unsubscribing the user. This issue on SO explains in more detail: https://stackoverflow.com/questions/42158460/autorenewable-subscription-iap-renewing-after-expiry-date-in-sandbox

@marcosmartinez7
Copy link
Author

marcosmartinez7 commented Oct 10, 2018

Thanks! Great info, could be the reason

About this issue, i think the @kevinEsherick code should be on the docs 👍 Its a great way to check subs!

@hyochan
Copy link
Member

hyochan commented Oct 10, 2018

Yes. I would appreciate a neat doc in readme if anyone of you request a PR. Thanks.

@hyochan
Copy link
Member

hyochan commented Oct 10, 2018

Hi guys. I've added Q & A section in readme. Hope anyone of you can give us a PR for this issue.

@kevinEsherick
Copy link

I'll look into making a PR in the next few days :)

@curiousdustin
Copy link
Contributor

I am also looking to use this library specifically for subscriptions. Both iOS and Android. Documenting the proper way to determine if a user has a valid subscription would be greatly appreciated. 😄

@hyochan
Copy link
Member

hyochan commented Oct 24, 2018

@curiousdustin Apple's implementation on this is really terrible if you refer to medium. Since you need to handle this kind of thing in your backend, we couldn't fully support this in our module. However, you can get availablePurchases in android using our method getAvailablePurchases which won't work on ios subscription product.

@curiousdustin
Copy link
Contributor

So are you saying the solution that @marcosmartinez7 and @kevinEsherick are working on in this thread is NOT a solution, and that a backend server other than Apple's is necessary to confirm the status of an iOS subscription?

From reading the Medium article you posted along with the Apple docs, it does seem that using a server is preferred, but it is not impossible to determine subscription status with device only.

@hyochan
Copy link
Member

hyochan commented Oct 25, 2018

Sorry that I've missed some information in my writing. I will manage this now.
Apple suggests to save the receipt in your own server to prevent from middle attack. Therefore you can later check that receipt to find out whether receipt is valid or subscription is still available.

However, it isn't impossible to still try out with react-native-iap since we provide the direct fetch of validating receipt to own Apple server (sandbox & production) which thankfully @marcosmartinez7 and @kevinEsherick worked out.

I think currently this is the solution to work out for checking ios subscription.

@kevinEsherick
Copy link

Hey guys, sorry it's been more than a few days, I've been caught up in work but I'll still submit that PR to update the README. @curiousdustin the method I wrote about above definitely works and I'll add that to the docs. I'll submit the PR tonight or tomorrow :)

@curiousdustin
Copy link
Contributor

Thanks the the update.

One more thing I noticed from your example.

'receipt-data': purchases[purchases.length - 1].transactionReceipt,
...
const expiration = renewalHistory[renewalHistory.length - 1].expires_date_ms

In my testing, purchases and renewalHistory aren't necessarily in any particular order. Wouldn't we need to sort them or something to verify we are using the one we intend to?

@andrewzey
Copy link

Same here. Purchases and renewalHistory are definitely not ordered, so we do have to sort first. In dev, that ends up being a lot of iterations.

@andrewzey
Copy link

andrewzey commented Oct 27, 2018

Here is the function I am using that works on both Android and iOS, properly sorting on iOS to ensure we get the latest receipt data:

import * as RNIap from 'react-native-iap';
import {ITUNES_CONNECT_SHARED_SECRET} from 'react-native-dotenv';

const SUBSCRIPTIONS = {
  // This is an example, we actually have this forked by iOS / Android environments
  ALL: ['monthlySubscriptionId', 'yearlySubscriptionId'],
}

async function isSubscriptionActive() {
  if (Platform.OS === 'ios') {
    const availablePurchases = await RNIap.getAvailablePurchases();
    const sortedAvailablePurchases = availablePurchases.sort(
      (a, b) => b.transactionDate - a.transactionDate
    );
    const latestAvailableReceipt = sortedAvailablePurchases[0].transactionReceipt;

    const isTestEnvironment = __DEV__;
    const decodedReceipt = await RNIap.validateReceiptIos(
      {
        'receipt-data': latestAvailableReceipt,
        password: ITUNES_CONNECT_SHARED_SECRET,
      },
      isTestEnvironment
    );
    const {latest_receipt_info: latestReceiptInfo} = decodedReceipt;
    const isSubValid = !!latestReceiptInfo.find(receipt => {
      const expirationInMilliseconds = Number(receipt.expires_date_ms);
      const nowInMilliseconds = Date.now();
      return expirationInMilliseconds > nowInMilliseconds;
    });
    return isSubValid;
  }

  if (Platform.OS === 'android') {
    // When an active subscription expires, it does not show up in
    // available purchases anymore, therefore we can use the length
    // of the availablePurchases array to determine whether or not
    // they have an active subscription.
    const availablePurchases = await RNIap.getAvailablePurchases();

    for (let i = 0; i < availablePurchases.length; i++) {
      if (SUBSCRIPTIONS.ALL.includes(availablePurchases[i].productId)) {
        return true;
      }
    }
    return false;
  }
} 

@marcosmartinez7
Copy link
Author

@andrewze

On the android scenario, if the subscription is auto-renewable it will be returned on availablePurchases

@kevinEsherick
Copy link

kevinEsherick commented Oct 30, 2018

@curiousdustin @andrewzey You're right, purchases is not ordered. renewalHistory/latest_receipt_info, however, is always ordered for me. I remember realizing that purchases wasn't ordered but thought I had found some reason that this wasn't actually of concern. I'll look into this in a couple hours once I get home. I don't want to submit a PR til we have this figured out.
EDIT: So the reason I don't think you need to sort the purchases is because expires_date_ms returns the same regardless of which purchase you've queried. Is this not the same for you guys? Or is there some bit of information that you need that requires sorting? Let me know your thoughts. I do agree that the docs should still make clear that purchases aren't ordered chronologically, but as far as I can tell sorting isn't needed to get the expiration date.

@andrewzey
Copy link

@kevinEsherick Thanks! In my case, neither the purchases nor renewalHistory were ordered.

@hyochan
Copy link
Member

hyochan commented Nov 3, 2018

@kevinEsherick @andrewzey Can you guys give PR on this solution? We'd like to share with others who are suffering.

@andrewzey
Copy link

Sure I'll prepare it right after we're done with our public app launch =).

@kevinEsherick
Copy link

I was waiting for more conversation regarding my last comment. I brought up some issues that remain unresolved. Please see above for reference. Once we get that sorted out one of us can make a PR.

@andrewzey
Copy link

andrewzey commented Nov 4, 2018

@kevinEsherick Ah sorry, I missed that.

expires_date_ms is definitely unique for me on each renewal receipt. Perhaps I've misunderstood? I noticed that the renewals receipt array attached to each decoded receipt is the same regardless of which receipt is selected, so I could see that you may be right in the following (from my example) not being required:

const sortedAvailablePurchases = availablePurchases.sort(
      (a, b) => b.transactionDate - a.transactionDate
    );

But I did it anyways in service of trying to be accurate, and compensating for possible discrepancies in the actual API response from what Apple documents. I don't think a sort operation there will be very expensive, given the total number of receipts you're likely to encounter for auto-renewing subscriptions.

@schumannd
Copy link
Contributor

Any ETA on the PR? I am considering moving over from this package as it does not handle this case at all.

@andrewzey
Copy link

andrewzey commented Nov 16, 2018

@schumannd Given that the PR would be only to update the docs, you can move over to react-native-iap now.

I've confirmed that my code snippet does in fact work without issue in production with our recently launched app: https://itunes.apple.com/us/app/rp-diet/id1330041267?ls=1&mt=8

EDIT I'll definitely make the PR (I have it in my "Give back to OSS" list in our Trello), but after the black friday weekend craziness 😄

@kevinEsherick
Copy link

@schumannd I second what @andrewzey said. Everything a PR would say is contained here in this post. I've been meaning to get around to it but it took us awhile to sort out exactly what needs to be done, and that mixed with travel and hectic startup hours mean I haven't had the time to PR it yet. I still plan to soon, but anyone else can step up in the meantime if they'd like. And @andrewzey congrats man! Looks great, I'll give it a download!

@stale
Copy link

stale bot commented May 22, 2019

Hey there, it looks like there has been no activity on this issue recently. Has the issue been fixed, or does it still require the community's attention? This issue may be closed if no further activity occurs. You may also label this issue as "For Discussion" or "Good first issue" and I will leave it open. Thank you for your contributions.

@stale
Copy link

stale bot commented Jun 22, 2019

Closing this issue after a prolonged period of inactivity. If this issue is still present in the latest release, please feel free to create a new issue with up-to-date information.

@alexpchin
Copy link

Perhaps this whole flow needs clarifying in the documentation?

Especially when it comes to the Auto-renewing subscription? It is very unclear to a newcomer whether you are able to check the expires_date_ms for whether the subscription is still active or not?

I suggest the best place for this would be in a more complete working example?

Would people be interested in this? If I get some time, I could work on this?

@albertkai
Copy link

@alexpchin yes that would definitely save a ton of dev time and your karma will get crystal clean:D

@hyochan
Copy link
Member

hyochan commented Nov 17, 2019

Agree! Would anyone be interested in #856 ?

@Recover-Athletics
Copy link

Recover-Athletics commented Feb 28, 2020

Hi,

@andrewzey's method has worked great for us until yesterday when we were suddenly no longer to validate any receipts for our subscriptions - we're getting a JSON parse error back from iOS when calling validateReceiptIos(). I take it no one else has ran into this...? The only change that was made since yesterday was the addition of a promo code on ITC which we have since removed to help eliminate variables. Is there any reason why a receipt should fail JSON parsing? It fails for every receipt returned - not just the receipt at index 0 of sortedAvailablePurchases. The code is basically identical to andrewzey's example - I've omitted everything after validateReceiptIos since the error is thrown there.

We're running RN 0.61.4, RNIap: 4.4.2, and iOS 13.3.1

We've tried:

  • Reinstalling app
  • New user account
  • Using a new app secret
  • Testing all receipts on each user - not a single receipt can't be parsed

We're wondering if we weren't using finishTransactionIOS correctly when we made the sandbox purchases?

What's really strange is when we validate the receipt using this online tool, everything looks normal and we can see all of the receipt metadata.

  isSubscriptionActive = async () => {
      const availablePurchases = await RNIap.getAvailablePurchases();
      const sortedAvailablePurchases = availablePurchases.sort(
        (a, b) => b.transactionDate - a.transactionDate
      );
      const latestAvailableReceipt = sortedAvailablePurchases[0].transactionReceipt;
      const isTestEnvironment = __DEV__;

      try {
        const decodedReceipt = await RNIap.validateReceiptIos(
          {
            'receipt-data': latestAvailableReceipt,
            password: Config.IN_APP_PURCHASE_SECRET,
          },
          isTestEnvironment
        );
        console.log('response!', decodedReceipt)
      } catch (error) {
        console.warn('Error validating receipt', error) // JSON PARSE ERROR HERE
      }
...

@doteric
Copy link

doteric commented May 11, 2020

Question regarding the code @andrewzey placed. Wouldn't Date.now() be the current device time and a user could possibly change this and have an endless subscription? 🤔

Also, isn't verifying the receipt from the app itself not discouraged?

@captaincole
Copy link

@doteric Could you point me to where they mention its discouraged? I know that you can set up server side notifications, but it seems much easier from my perspective to handle this validation on the client instead of the server.
I'm struggling to find the right documentation from apple or other solid react native sources.

@kevinEsherick
Copy link

@doteric yes Date.now() is the device time so it could be worked around. Yet the chances of a user doing this are minuscule, and even other methods could be worked around for an endless subscription. For example if using a simple server side validation, they could enter airplane mode prior to opening the app to prevent the app from realizing the subscription is expired. Of course there are other protections you could put in place, but my point is that client side using Date.now() is certainly functional. @captaincole I don't have the docs on hand but I can confirm that I too have read that client side validation is discouraged. I read it in Apple docs I believe. That being said, I think client side gets the job done.

@asobralr
Copy link

Hi,

I was trying one of the approaches discussed in this thread, using validateReceiptIos and the latest_receipt_data (comparing expires_date_ms with actual date). It worked great while developing in Xcode. However, when I tested it in Testflight it didn't work anymore. Its hard to debug so I am not being able to identify the exact problem, it seems it is not getting the receipt data. Did anyone have a similar issue with testflight?

Thanks in advance!

@Somnus007
Copy link

Hi,

I was trying one of the approaches discussed in this thread, using validateReceiptIos and the latest_receipt_data (comparing expires_date_ms with actual date). It worked great while developing in Xcode. However, when I tested it in Testflight it didn't work anymore. Its hard to debug so I am not being able to identify the exact problem, it seems it is not getting the receipt data. Did anyone have a similar issue with testflight?

Thanks in advance!

+1

@kevinEsherick
Copy link

@asobralr @Somnus007 I may be wrong, but I don't think you can test IAPs with testflight since users can't make purchases through testflight. Apple does not provide great opportunities for testing purchases in production-like environments

@acostalima
Copy link

@asobralr @Somnus007 I may be wrong, but I don't think you can test IAPs with testflight since users can't make purchases through testflight. Apple does not provide great opportunities for testing purchases in production-like environments

Correct. You can't even simulate failure scenarios in sandbox, which is a shame. 😞

@Somnus007
Copy link

Hi @kevinEsherick , thanks for your reply. You may be wrong. User can make purchase through teslflight. On testflight, it seems that user have to login a sandbox account which has the same email address with its real apple account. Then everything works well. BTW, will getPurchaseHistory trigger the Itunes login popup in production env?

@the-dut
Copy link

the-dut commented Sep 24, 2020

Looks like ios 14 breaks this solution due to the fact that there are issues when you call getAvailablePurchases() before calling requestSubscription().

@aleksandarbos
Copy link

I'm working on figuring this out as well. I haven't tested it yet, but from what I'm reading I'm gathering that it's possible by creating a "shared secret" in iTunes Connect and passing this to validateReceiptIos with the key 'password'. I believe this will then return a JSON object which will contain a code indicating the validation status of the subscription and the keys latest_receipt, latest_receipt_info, and latest_expired_receipt info, among others, which you can use to determine the subscription status. I am literally just figuring this out so I have yet to test it. It's what I'm putting together from the issues and Apple's docs. If this works, it really should be made very clear in the documentation instead of being buried in the issues. I believe the following links are relevant: https://developer.apple.com/library/archive/releasenotes/General/ValidateAppStoreReceipt/Chapters/ValidateRemotely.html #203 #237

EDIT: I can confirm that the process I mentioned above works. I think we should work to get a full explanation of this in the docs. I would implement something like this on app launch to determine whether a subscription has expired:

RNIap.getPurchaseHistory()
        .then(purchases => {
                RNIap.validateReceiptIos({
                   //Get receipt for the latest purchase
                    'receipt-data': purchases[purchases.length - 1].transactionReceipt,
                    'password': 'whateveryourpasswordis'
                }, __DEV__)
                    .then(receipt => {
                       //latest_receipt_info returns an array of objects each representing a renewal of the most 
                       //recently purchased item. Kinda confusing terminology
                        const renewalHistory = receipt.latest_receipt_info
                       //This returns the expiration date of the latest renewal of the latest purchase
                        const expiration = renewalHistory[renewalHistory.length - 1].expires_date_ms
                       //Boolean for whether it has expired. Can use in your app to enable/disable subscription
                        console.log(expiration > Date.now())
                    })
                    .catch(error => console.log(`Error`))
        })

had some troubles until i've passed object like:

  return RNIap.validateReceiptIos({
    receiptBody: {
      "receipt-data":
        purchaseHistory[purchaseHistory.length - 1].transactionReceipt,
      password: sharedIOSSecret,
    },
    isTest: __DEV__,
  })
    .then((receipt) => {
    ...

with receiptBody and isTest, wouldn't load the full receipt for some reason if i didn't do that.

minor thing: think that last expiration > Date.now() if it has expired then the expiration shall be lesser than this current date therefore expiration < Date.now().

here's my update:

const validateIOSReceipt = (sharedIOSSecret, purchaseHistory) => {
  if (!sharedIOSSecret) {
    console.log("No shared iOS secret available.");
    return;
  }
  return RNIap.validateReceiptIos({
    receiptBody: {
      "receipt-data":
        purchaseHistory[purchaseHistory.length - 1].transactionReceipt,
      password: sharedIOSSecret,
    },
    isTest: __DEV__,
  })
    .then((receipt) => {
      const renewalHistory = receipt.latest_receipt_info;
      const expiration =
        renewalHistory[renewalHistory.length - 1].expires_date_ms;
      return expiration < Date.now();
    })
    .catch((error) =>
      console.log(`Error, unable to validate ios subscription receipt`, error)
    );
};

@NikhilPacewisdom
Copy link

I'm working on figuring this out as well. I haven't tested it yet, but from what I'm reading I'm gathering that it's possible by creating a "shared secret" in iTunes Connect and passing this to validateReceiptIos with the key 'password'. I believe this will then return a JSON object which will contain a code indicating the validation status of the subscription and the keys latest_receipt, latest_receipt_info, and latest_expired_receipt info, among others, which you can use to determine the subscription status. I am literally just figuring this out so I have yet to test it. It's what I'm putting together from the issues and Apple's docs. If this works, it really should be made very clear in the documentation instead of being buried in the issues. I believe the following links are relevant: https://developer.apple.com/library/archive/releasenotes/General/ValidateAppStoreReceipt/Chapters/ValidateRemotely.html #203 #237

EDIT: I can confirm that the process I mentioned above works. I think we should work to get a full explanation of this in the docs. I would implement something like this on app launch to determine whether a subscription has expired:

RNIap.getPurchaseHistory()
        .then(purchases => {
                RNIap.validateReceiptIos({
                   //Get receipt for the latest purchase
                    'receipt-data': purchases[purchases.length - 1].transactionReceipt,
                    'password': 'whateveryourpasswordis'
                }, __DEV__)
                    .then(receipt => {
                       //latest_receipt_info returns an array of objects each representing a renewal of the most 
                       //recently purchased item. Kinda confusing terminology
                        const renewalHistory = receipt.latest_receipt_info
                       //This returns the expiration date of the latest renewal of the latest purchase
                        const expiration = renewalHistory[renewalHistory.length - 1].expires_date_ms
                       //Boolean for whether it has expired. Can use in your app to enable/disable subscription
                        console.log(expiration > Date.now())
                    })
                    .catch(error => console.log(`Error`))
        })

with this if after plan expiration if we validate the receipt token it adds new entry with extended expiry_date

@anatoolybinerals
Copy link

How implement apple button 'Restore Pusrchases' in 2024?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
📱 iOS Related to iOS 🚶🏻 stale Stale
Projects
None yet
Development

No branches or pull requests