jamesmontemagno / InAppBillingPlugin

Cross-platform In App Billing Plugin for .NET
MIT License
650 stars 152 forks source link

[Question] How to check if a renewable subscription is active? #311

Closed WorldOfBasti closed 2 years ago

WorldOfBasti commented 4 years ago

Hello, I am using the InAppBilling plugin to purchase different renewable subscriptions. First i want to set it up for iOS. It works good, but now I have to check if the subscription is active or canceled. I tried it with this code snippet:

var purchases = await CrossInAppBilling.Current.GetPurchasesAsync(ItemType.Subscription);
if(purchases == null)
{
    return false;
}
else
{
    foreach(var subscription in purchases)
    {
        Console.WriteLine(subscription);
    }
    return true;
}

The problem is, that this returns any purchase, including the canceled ones. The output for every purchase looks like this: ProductId:com.myapp.subscription1 | AutoRenewing:False | State:Purchased | Id:myProductId

I am using the latest stable version of the plugin (2.0.0). I also asked a question on the Xamarin community forums, but no answer yet. Maybe someone can help me, thanks in advance.

ChristianGeiger2 commented 4 years ago

Hallo,

do you have any updates on this issue ? I tried V4.0.0.beta, but there is also this issue

WorldOfBasti commented 4 years ago

Hi @ChristianGeiger2, I think the only solution is to check if the subscription is still auto renewing. If it is auto renewing the user gets access to those features and if not, we need to get the date when it was purchased. Then we can check if the month/year/etc. is expired or not.. . A problem which I am noticed is, that the auto renewing boolean is always false, but I think this is a bug in apple sandbox..

ChristianGeiger2 commented 4 years ago

Hallo @WorldOfBasti

i was debugging and i found a workaround. It is not pretty but currently it is working for IOS.

  1. get the actual code from github
  2. replace the nuget with the Plugin.InAppBilling prj. code (unfortunally in the the 4.0.0.beta the token is still missing)
  3. load the purchases and verify the token -> expire-date (requires plist-cil )

    private bool IsPurchaseValid([CanBeNull] InAppBillingPurchase purchase)
        {
            if (Device.RuntimePlatform == Device.Android)
            {
                return purchase != null;
            }
    
            if (Device.RuntimePlatform == Device.iOS)
            {
                if (purchase == null) return false;
    
                var token = purchase.PurchaseToken;
                if (string.IsNullOrEmpty(token)) return false;
    
                var data1 = System.Convert.FromBase64String(token);
                if (!data1.Any()) return false;
    
                var receiptDic = (NSDictionary)PropertyListParser.Parse(data1);
                if (receiptDic == null) return false;
    
                var purchaseInfoContent = receiptDic.ObjectForKey("purchase-info").ToString();
                if (string.IsNullOrEmpty(purchaseInfoContent)) return false;
    
                byte[] data2 = System.Convert.FromBase64String(purchaseInfoContent);
                var purchaseInfoDic = (NSDictionary)PropertyListParser.Parse(data2);
                if (purchaseInfoDic == null) return false;
    
                var msStr = purchaseInfoDic.ObjectForKey("expires-date").ToString();
                if (string.IsNullOrEmpty(msStr)) return false;
    
                // Etc/GMT
                var expireDate = new DateTime(1970, 1, 1).AddMilliseconds(Convert.ToInt64(msStr));
    
                return DateTime.Now.ToUniversalTime() < expireDate;
            }
            return false;
        }

Just a workaround but i hope this helps.

ChristianGeiger2 commented 4 years ago

According to this: Handling Subscriptions Billing ... **There are two types of subscriptions you can offer in your app: non-renewing and auto-renewable. Non-renewing subscriptions differ from auto-renewable subscriptions in a few key ways. These differences give your app the flexibility to implement the correct behavior for a non-renewing subscription, as follows:

Your app is responsible for calculating the time period that the subscription is active and determining what content needs to be made available to the user.

Your app is responsible for detecting that a non-renewing subscription is approaching its expiration date and prompting the user to renew the subscription by purchasing the same product again.

Your app is responsible for making purchased subscriptions available across all the user’s devices and for letting users restore past purchases. For example, most subscriptions are provided by a server; your server would need some mechanism to identify users and associate subscription purchases with the user who purchased them.**

WorldOfBasti commented 4 years ago

Thank you @ChristianGeiger2 very much, this works really good. I came across the next problem: If the user starts the app another time, we need to check if any subscription is active/canceled/etc. so the user get access to those features.. Is there any solution?

HolzetheKid commented 4 years ago

to get this information you need to validate the receipt against the apple Server. If you do this you will get something like this apple responsebody

I have not tested this lib, but it looks good! --> AppleReceiptVerifier --> response class

nrcpp commented 4 years ago

Hello @HolzetheKid , Thanks for sharing this workaround. Will it work once after auto-renewable subscription updates for next week/month/year? E.g. if we have monthly subscription, then will IsPurchaseValid method has updated 'expires-date' field to compare on next month?

ChristianGeiger2 commented 4 years ago

@nrcpp as soon as i know it should work. --When an apple subscription is expired and auto renew is on, you will get an new InAppBillingPurchase purchase from apple. This is a difference between to google and apple. apple always sends you every purchase you made in the past including the active one, where google sends you only the current one. I am also pretty new to this stuff, so please test.

blmiles commented 3 years ago

Hello all, seems like I'm not the only one seeing these issues re: auto-renewing subscriptions.

@ChristianGeiger2 - you quoted this from the Apple docs- "Your app is responsible for detecting that a non-renewing subscription is approaching its expiration date and prompting the user to renew the subscription by purchasing the same product again."

I have only 1 IAP in the app store and it is a monthly auto-renewing subscription and set as such in the item in the store. The AutoRenewing flag is ALWAYS false in the purchase object returned from GetPurchasesAsync() or PurchaseAsync();

I assume that must be a bug in data coming from the App Store OR the InAppBilling plugin isn't interpreting the data correctly. Also, I'm using a sandbox account so that also might be affecting what is returned. (...just THINKING...)

QUESTION - If the User has cancelled AutoRenewing but that flag is always returned as FALSE, how do we detect that that subscription IS actually cancelled. OR even continuing for that matter...

QUESTION - does anyone know if the AutoRenewing flag (with the CORRECT value) is contained in the PurchaseToken?

Any further advice/suggestion in handling auto-renewing subscriptions would be most welcome! @jamesmontemagno?

IAP is so fundamental to building apps and Xamarin is such a great framework to do so, this functionality should really be cleaned up and made far more robust and PROBABLY SHOULD be part of the Xamarin.Essentials plugin. This should not be so difficult to make work! Even the IsPurchaseValid() method like the one posted above to parse up the PurchaseToken should be part of this framework. Just my opinion really.

WorldOfBasti commented 3 years ago

Hello @blmiles, I also noticed these strange bugs. I think this is a bug from apple sandbox and won't be there in production as well. To answer your first question: As I understood it, apple sends you always a new purchase if the current running time has expired (and the subscription wasn't cancelled). This means you only need to check if the subscription is active by the method from @ChristianGeiger2..

If I understand anything wrong please correct me!

blmiles commented 3 years ago

@WorldOfBasti thanks for reply.

So, in your experience, you're seeing the AutoRenewing flag set appropriately from a production account? ie: in my case I'd expect that to be returned as TRUE unless the user has cancelled the subscription. In that case I'd provide the user access from the transactionDate to the transactionDate + 30 days. Being a monthly subscription.

And yes, I'm implementing @ChristianGeiger2 IsPurchaseValid method.

Also, if the purchase is renewable, the purchase.Id remains the same I believe, ie: it is the same purchase but renewed with a new PurchaseToken. Right? :)

Handling the life-cycle of these auto-renewing subscription purchases seems quite a juggle. Maybe I should hold off trying to deal with Promo items :)

EDIT: Just step through the IsPurchaseValid method. That reveals a LOT! InAppBilling should definitely have this method and use it to fill properties on the Purchase object!

Considering I'm using a sandbox appleID/account to make the purchase on my test device, sandbox purchase data is NOT what I'd expect from the prod env. That's really frustrating as we'd have to guess and hope for the best making a purchase using a live apple account actually works. Still no indication in the PurchaseToken on AutoRenewable or not. No dic item for that...

HolzetheKid commented 3 years ago

Hallo , i cannot test right know, but it seems that you need to get the _'pending_renewalinfo' link from apple.

With the code above, you see that _'latest_receiptinfo' link is stored in the Token. Maybe the complete 'unified_receipt' link is stored there! I am not able to thest this right now.

Hopfully this information helps!

blmiles commented 3 years ago

@HolzetheKid, good idea but I THINK that if the AutoRenew is returned as false, there shouldn't be '_pending_renewalinfo' available. Part of the issue is AutoRenewing is ALWAYS false. Even on an auto-renewing subscription... :( However, a very good suggestion, will see what I can extract!

EDIT: Just tested and pending_renewal_info is NOT returned in this particular Purchase object. I get 'purchase-info' with no data related to the AutoRenewable subscription. Will keep trying but if the App store isn't returning what it should, not sure how to make this work... I also see, ConsumptionState = NotYetConsumed and IsAcknowledged=false. Wondering if there was some other action to perform on the Purchase object to finalise the transaction.

blmiles commented 3 years ago

Hello,

After running a purchase object through @ChristianGeiger2 IsPurchaseValid method, I get these named data items returned in the PurchaseToken:

[0:] 0: original-purchase-date-pst [0:] 1: quantity [0:] 2: subscription-group-identifier [0:] 3: unique-vendor-identifier [0:] 4: original-purchase-date-ms [0:] 5: expires-date-formatted [0:] 6: is-in-intro-offer-period [0:] 7: purchase-date-ms [0:] 8: expires-date-formatted-pst [0:] 9: is-trial-period [0:] 10: item-id [0:] 11: unique-identifier [0:] 12: original-transaction-id [0:] 13: expires-date [0:] 14: transaction-id [0:] 15: bvrs [0:] 16: web-order-line-item-id [0:] 17: bid [0:] 18: product-id [0:] 19: purchase-date [0:] 20: purchase-date-pst [0:] 21: original-purchase-date

[0:] 0: 2020-11-27 14:17:19 America/Los_Angeles [0:] 1: 1 [0:] 2: 20712060 [0:] 3: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx [0:] 4: 1606515439000 [0:] 5: 2020-11-27 22:22:18 Etc/GMT [0:] 6: false [0:] 7: 1606515438000 [0:] 8: 2020-11-27 14:22:18 America/Los_Angeles [0:] 9: false [0:] 10: 1542213371 [0:] 11: 00008020-0012596A0CE8402E [0:] 12: 1000000747513385 [0:] 13: 1606515738000 [0:] 14: 1000000750794642 [0:] 15: 1.0 [0:] 16: 1000000057810340 [0:] 17: com.xxxxxxxxxx.xxxxxxxxxxxx [0:] 18: MyProdId001 [0:] 19: 2020-11-27 22:17:18 Etc/GMT [0:] 20: 2020-11-27 14:17:18 America/Los_Angeles [0:] 21: 2020-11-27 22:17:19 Etc/GMT

Looking at the 'purchase-date', 'expires-date', one can see the differences (5 min diff in sandbox).

My in-app-purchase item IS a monthly auto-renewable item and the correct 'product-id' is being returned.

NOTE: There is NO reference to an AutoRenewable subscription, NO reference to any Grace Period or any field related to the _Pending_renewalinfo data.

QUESTION, by making this call: var purchase = await billing.PurchaseAsync(productId, ItemType.Subscription, verify);

How does one get '_Pending_renewalinfo' data?

Is this an InAppBilling issue/error or is this errant/incomplete data coming from the App Store and testing Sandbox mode?

@HolzetheKid @ChristianGeiger2 ? @jamesmontemagno ?

Anyone else?

HolzetheKid commented 3 years ago

Looking at the 'purchase-date', 'expires-date', one can see the differences (5 min diff in sandbox). The Sandbox has different times than the "real life" where 5 min means 1 month in real life link

For the other problem i cannot help you. I don't know what apple is sending you there and how the data is usable.

App Store Subscription/Trial Duration Sandbox Duration
3 days 2 minutes
1 week 3 minutes
1 month 5 minutes
2 months 10 minutes
3 months 15 minutes
6 months 30 minutes
1 year 1 hour
blmiles commented 3 years ago

LOL, @HolzetheKid I know that, I was just stating the obvious so that you'd be aware I knew I was in sandbox mode. At least the date/time of purchase and expires date/time are being set. It is other subscription and auto-renewal data that is not being returned properly.

You had cited the 'Pending_renewal_info' data. Have you been able to get that out of a Purchase object? Like the fields listed in the LINK you posted?

I'm going to go with the understanding I know my item in the App Store is an Auto-Renewable monthly subscription. Also, the 'Grace Period' is also ON for my IAP in the store.

I guess I just need to continue with those two details as assumed known constants and complete my code accordingly without this futile battle to find crap that just isn't forthcoming.

And I haven't even looked at handling Promotion items or 'intro-offer-period' yet ... heaven forbid, LOL. Maybe I'll post my code when I'm satisfied with it as I'm sure many others are fighting a similar battle.

blmiles commented 3 years ago

Hallo @WorldOfBasti

i was debugging and i found a workaround. It is not pretty but currently it is working for IOS.

  1. get the actual code from github
  2. replace the nuget with the Plugin.InAppBilling prj. code (unfortunally in the the 4.0.0.beta the token is still missing)
  3. load the purchases and verify the token -> expire-date (requires plist-cil )
  private bool IsPurchaseValid([CanBeNull] InAppBillingPurchase purchase)
       {
           if (Device.RuntimePlatform == Device.Android)
           {
               return purchase != null;
           }

           if (Device.RuntimePlatform == Device.iOS)
           {
               if (purchase == null) return false;

               var token = purchase.PurchaseToken;
               if (string.IsNullOrEmpty(token)) return false;

               var data1 = System.Convert.FromBase64String(token);
               if (!data1.Any()) return false;

               var receiptDic = (NSDictionary)PropertyListParser.Parse(data1);
               if (receiptDic == null) return false;

               var purchaseInfoContent = receiptDic.ObjectForKey("purchase-info").ToString();
               if (string.IsNullOrEmpty(purchaseInfoContent)) return false;

               byte[] data2 = System.Convert.FromBase64String(purchaseInfoContent);
               var purchaseInfoDic = (NSDictionary)PropertyListParser.Parse(data2);
               if (purchaseInfoDic == null) return false;

               var msStr = purchaseInfoDic.ObjectForKey("expires-date").ToString();
               if (string.IsNullOrEmpty(msStr)) return false;

               // Etc/GMT
               var expireDate = new DateTime(1970, 1, 1).AddMilliseconds(Convert.ToInt64(msStr));

               return DateTime.Now.ToUniversalTime() < expireDate;
           }
           return false;
       }

Just a workaround but i hope this helps.

@ChristianGeiger2 Have you done something similar with Android purchase tokens? I cannot seem to find a reference to what is in that token from the Play Store. Also finding it difficult to implement/test/debug as we can only hit the Play Store from a signed release-mode app.

Any help/advice would be great! Works great dealing with iOS purchases!

WorldOfBasti commented 3 years ago

For my tests I can say, that you don't need something similar for android. Google sends you only a purchase if the subscription is active, so you only need to check if the purchases are null.

Here an example which you can find in the InAppBilling docs:

        //check for null just incase
        if(purchases?.Any(p => p.ProductId == productId) ?? false)
        {
            //Purchase restored
            return true;
        }
        else
        {
            //no purchases found
            return false;
        }

It works fine for me..

blmiles commented 3 years ago

Thanks @WorldOfBasti, I'll give it a try.

I don't particularly like it but if that's what Google does then I guess we've gotta go with it...

EDITED FOR CLARIFICATION It'd still be good to parse up the purchase token though so I can store certain parts of the data, like the expires-date from the Apple purchase token, IF that data is at all available in the PlayStore purchase token.

Some of my users don't always have constant online connection so I store the purchaseToken locally. When app starts, I check the expires-date of the token and if it fails, THEN check with the app/play stores with GetPurchasesAsync(). If that fails, ask them to subscribe.

That works well for iOS. I'd like to find the "expires-date" in an Android purchase IF at all possible so that I can do the same instead of booting the user out if they cannot connect to the store to GetPurchasesAsync(). Right now I can only guess based on the TransactionDateUtc.

So, it'd be great to get guidance from some of the team here. @jamesmontemagno? Anyone?

Thx

SerkanKrkc commented 3 years ago

@Blmiles

Do you have any idea how the results below work in a production environment? Have you had a chance to test it?

I am particularly curious about the AutoRenewing and IsAcknowledged result.

Thanks in advance.

"Id": "xxxxxxxxxxxxxxxx", "TransactionDateUtc": "2021-03-29T20:21:54Z", "ProductId": "xxx", "AutoRenewing": false, "PurchaseToken": "xxxxxxxxxxxxxxxxxxx", "State": 0, "ConsumptionState": 0, "IsAcknowledged": false, "Payload": null

jamesmontemagno commented 3 years ago

I don't use subscriptions, but I would assume on iOS you just loop through all of your purchases, find the newest, and calculate if it is valid for the current time span.

jamesmontemagno commented 2 years ago

https://montemagno.com/ios-android-subscription-implemenation-strategies/

jho123456 commented 1 year ago

Hi @jamesmontemagno ,

I've just dropped you a msg on this issue on another comments board and then came across this board where the issue is discussed in more depth. I've read through your subscription strategies blog above but the issue the commenters raise above is not covered and the problem remains: i.e. the AutoRenewing flag is always set to false in iOS sandbox testing, even when a brand new Auto-Renewable Subscription has been purchased. I've gone through your InAppBilling code and could be wrong, but can't see where this value is being assigned for iOS.

Is there anyway we can get this value through the plugin?

This is needed not necessarily to calculate whether a subscription is still active, as that can be done via transaction dates, but more to give the user meaningful feedback through the app UI, of the status of their subscription.

GioviQ commented 1 week ago

Now you can NSPropertyListSerialization without plist-cil library