jamesmontemagno / InAppBillingPlugin

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

iOS out-of-band InAppBillingImplementation.OnPurchaseComplete called more than once with two different product tokens for the same purchase cycle. #214

Closed RobbiewOnline closed 2 years ago

RobbiewOnline commented 5 years ago

Bug Information

This is specific to the iOS implementation.

We were seeing issues with out-of-band InAppBilling messages not being processed, so we added the suggested InAppBillingImplementation.OnPurchaseComplete handler. On iOS we rely on the OnPurchaseComplete handler to continue processing the purchase, instead of processing the awaited PurchaseAsync method.

To emulate an out-of-band purchase appears to be difficult with TestFlight because it's a sandbox account and therefore 2FA / PayPal integration isn't available and therefore I don't think we can easily cause the out-of-band messages - these usually are encountered by a user using the app from the App Store (live) e.g. when the user was using PayPal, expired cards, or two-factor authentication.

The closest I've found is to initiate the purchase, enter the sandbox password, then press the home button quickly. This causes the app to go into the background and seems to encourage multiple callbacks in some circumstances.

The problem is that I'm seeing the same details being passed into the handler, but the product token seems to update. Our server will only permit one use of the receipt and therefore the first invocation works and the second one is rejected.

Initially I'm filtering the 2nd message, but this does not feel right.

Version Number of Plugin: 2.1.0.187-beta Device Tested On: iPhone 6+ Simulator Tested On: N/A (In App Purchases don't work in simulator?) Version of VS: Mac 7.8.3 (build 2) Version of Xamarin: 3.2.0.871581 Versions of other things you are using:

Steps to reproduce the Behavior

I have only tried this as

  1. Initiate the purchase
  2. Enter the sandbox password
  3. Press the home button immediately after submitting the password (this causes the app to go into the background)
  4. Wait 10-20 seconds
  5. Resume the app
  6. The InAppBillingImplementation.OnPurchaseComplete handler is invoked twice for one purchase, but with different purchase tokens

Expected Behavior

InAppBillingImplementation.OnPurchaseComplete handler to be called once with the 'Purchased' state.

Actual Behavior

InAppBillingImplementation.OnPurchaseComplete handler is called twice with two different purchase tokens.

I wasn't expecting to see two events with the same 'Purchased' status, in fact all of these fields are the same...

The 1st and 2nd invocation of the handler however has a different purchase token...

Is it possible that one of them should be indicating a different status than purchased? If not then why do we receive two different product tokens? Is this a TestFlight quirk? Would this happen when released to the AppStore?

Logging output

I think the consumptionState just defaults to NoYetConsumed and doesn't change?

Event 1

VS 2019-03-15T10:13:13Z  UI:1 - AppDelegate - iOS OnPurchaseComplete event
consumptionState:NoYetConsumed 
id:1000000510641153 
productId:com.example.myproduct 
payload: 
State:Purchased 
TransactionDate:15/03/2019 10:13:07 
PurchaseToken:ewoJInNpZ25hdHVyZSIgPSAiQXltWXFINWxoOE01SC9zOXhyeGJLODRYUVQzelBpQ1RuVitXU1JkSUFiN0Zrd2NwZkJ3d1JGeVpicFVxaDZ0Mlo0V0lTRXRMZD.......0gIlNhbmRib3giOwoJInBvZCIgPSAiMTAwIjsKCSJzaWduaW5nLXN0YXR1cyIgPSAiMCI7Cn0=

Event 2

VS 2019-03-15T10:13:15Z  UI:1 - AppDelegate - iOS OnPurchaseComplete event 
consumptionState:NoYetConsumed 
id:1000000510641153 
productId:com.example.myproduct 
payload: 
State:Purchased 
TransactionDate:15/03/2019 10:13:07 

PurchaseToken:ewoJInNpZ25hdHVyZSIgPSAiQXorb3pYTlZieEJGSE9iUnlNWWdOYzhCck5GTkNTTkZhZ3lqWnJqT2MzRnFFelkwVEJaS1F4SGV3MExtU3FBZng1cHU0VzlGK.....0gIlNhbmRib3giOwoJInBvZCIgPSAiMTAwIjsKCSJzaWduaW5nLXN0YXR1cyIgPSAiMCI7Cn0=

Additional details

Our server captures the receipt signature during the verification method and stores/persists it, then when the OnPurchaseComplete handler is invoked it delegates through to our PurchaseManager class that passes the stored receipt to the server for verification, it passes on the first invocation of the OnPurchaseComplete handler (because the receipt hasn't be used before and Apple confirms verification), but on the 2nd invocation it fails (because the receipt has been verified and used).

I suspect that if I skipped the first PurchaseToken it would process the second PurchaseToken without issue, but I'm not comfortable that I've received two different purchase tokens.

Having two product tokens implies that the user purchased two different products? But given the purchase ID and ProductId is the same then I know it shouldn't be - besides the user has only authorised payment once.

I haven't seen this when testing in debug/sandbox mode (yet), but when deployed to TestFlight I do see the two calls to OnPurchaseComplete.

Android v iOS

On Android we explicitly 'consume' the purchase, but I believe on iOS this is not required. I have however seen some references to marking transactions as finished - is this done internally to the plugin or do we need to explicitly do this when using the OnPurchaseComplete mechanism?

Code snippet

Within AppDelegate...

            // On IOS we might receive out-of-band events to indicate that a purchase
            // has been completed.  Rather than accidentally processing them twice
            // (once in the PurchaseManager and once here) we only process them
            // here and prevent the PurchaseManager from handling the purchase
            // directly.
            InAppBillingImplementation.OnPurchaseComplete = async (purchase) =>
            {

                Logger.Log(this, $"OnPurchaseComplete invoked with autoRenewing:{purchase.AutoRenewing} ConsumptionState:{purchase.ConsumptionState} Id:{purchase.Id} Payload:{purchase.Payload} ProductId:{purchase.ProductId} State:{purchase.State} TransactionDateUtc:{purchase.TransactionDateUtc} \n\nPurchaseToken:{purchase.PurchaseToken}\n\n");

                // My attempt to filter the 2nd invocation if the Id and PurchaseState is the same
                if (lastPurchase != null)
                {
                    if (purchase.Id.Equals(lastPurchase.Id) && purchase.ConsumptionState.Equals(lastPurchase.ConsumptionState))
                    {
                        if (purchase.PurchaseToken.Equals(lastPurchase.PurchaseToken))
                        {
                            Logger.Log(this, $"OnPurchaseComplete threw away - same ID and consumption state");
                        }
                        else
                        {
                            Logger.Log(this, $"OnPurchaseComplete threw away - same ID and consumption state - BUT different PurchaseToken!");
                        }
                        return;
                    }
                }

                this.lastPurchase = purchase;

                PurchaseManager.ContinuePurchase(purchase);
        }

Screenshots

I've drawn a sequence diagram to explain the lifecycle, there's no screenshot that will add any real value

App in background and we see confirmation the purchase was completed... App in background and we see confirmation the purchase was completed

Resume the app and the second invocation fails purchase verification because the receipt has already been verified/used.

UML Sequence Diagram - the lifecycle UML Sequence Diagram

RobbiewOnline commented 5 years ago

I wasn't aware of this mechanism to obtain the receipt on iOS, so I've added it to the AppDelegate:OnPurchaseComplete for logging ...

var receiptUrl = NSData.FromUrl(NSBundle.MainBundle.AppStoreReceiptUrl);
var receipt = receiptUrl.GetBase64EncodedString(NSDataBase64EncodingOptions.None);
Log.info(receipt);

I am persisting the signed data that the plugins VerifyPurchase method was passed, when comparing that to when the AppDelegate is invoked (using the AppStoreReceiptUrl mechanism) then I see that

  1. the same signature is present in the first invocation of the OnPurchaseComplete
  2. a completely different signature and purchase token is seen in the 2nd invocation.

Could it be that TestFlight is invoking performing two purchases? Otherwise what reason would there be to have two different signature (with two different purchase tokens)?

I am wondering what would happen if I change my app to not persist the original signed data from the verification step and instead relied on what's visible in the AppDelegate - maybe my server-side verification would then accept both (because neither of the signed receipts would have been used before) and could therefore result in a double-purchase in the back-end, which actually costs us money each time as we buy data from our suppliers at this point.

jamesmontemagno commented 2 years ago

closing due to age of issue and mass changes in library

mnxamdev commented 1 year ago

We're actually having this issue in iOS production/appstore releases, it happens for a few customers a day. @RobbiewOnline did you ever figure out this issue? @jamesmontemagno do you have any insight on this? We are on InAppBillingPlugin v6.7.0 and Xamarin.Forms 5.0.0.2291 with Prism.Forms 8.1.97. For us I'm seeing OnPurchaseComplete firing a first time with what seems to be the correct TransactionId and ProductId, then we see OnPurchaseComplete firing again with a PREVIOUS purchase's (possibly purchased within same session) TransactionId and ProductId. Here's an excerpt of our setup. Note also that the transactionId and the receipt's transactionId don't match in these cases where we notice the OnPurchaseComplete firing multiple times.

public override bool FinishedLaunching(UIApplication app, NSDictionary options)
        {
            //start forms app
        global::Xamarin.Forms.Forms.Init();

        //other startup code removed

            Plugin.InAppBilling.InAppBillingImplementation.OnPurchaseComplete = async purchase =>
            {
                try
                {
                   //log OnPurchaseComplete fired with transactionId and ProductId

                    var receiptUrl = NSData.FromUrl(NSBundle.MainBundle.AppStoreReceiptUrl);
                    var receipt = receiptUrl.GetBase64EncodedString(NSDataBase64EncodingOptions.None);

                    await verifyIAP.VerifyPurchase(receipt, string.Empty, purchase.ProductId, purchase.Id);
                }
                catch(Exception ex)
                {
                  //log
                }
            }

            return base.FinishedLaunching(app, options);
         }
RobbiewOnline commented 1 year ago

Hi

I don't remember the specifics, but from memory I persisted the receipt (or it might have been only the transaction / purchase ID), then if we see it again (within the same app launch) then we simply ignore it.

Kind Regards,

Rob.

On 11 May 2023, at 19:38, mnxamdev @.***> wrote:



We're actually having this issue in iOS production/appstore releases, it happens for a few customers a day. @RobbiewOnlinehttps://github.com/RobbiewOnline did you ever figure out this issue? @jamesmontemagnohttps://github.com/jamesmontemagno do you have any insight on this? We are on InAppBillingPlugin v6.7.0 and Xamarin.Forms 5.0.0.2291 with Prism.Forms 8.1.97. For us I'm seeing OnPurchaseComplete firing a first time with what seems to be the correct TransactionId and ProductId, then we see OnPurchaseComplete firing again with a PREVIOUS purchase's (possibly purchased within same session) TransactionId and ProductId. Here's an excerpt of our setup:

`public override bool FinishedLaunching(UIApplication app, NSDictionary options) { //start forms app global::Xamarin.Forms.Forms.Init();

//other startup code removed

    Plugin.InAppBilling.InAppBillingImplementation.OnPurchaseComplete = async purchase =>
    {
        try
        {
           //log OnPurchaseComplete fired with transactionId and ProductId

            var receiptUrl = NSData.FromUrl(NSBundle.MainBundle.AppStoreReceiptUrl);
            var receipt = receiptUrl.GetBase64EncodedString(NSDataBase64EncodingOptions.None);

            await verifyIAP.VerifyPurchase(receipt, string.Empty, purchase.ProductId, purchase.Id);
        }
        catch(Exception ex)
        {
          //log
        }

    return base.FinishedLaunching(app, options);
 }

`

— Reply to this email directly, view it on GitHubhttps://github.com/jamesmontemagno/InAppBillingPlugin/issues/214#issuecomment-1544499508, or unsubscribehttps://github.com/notifications/unsubscribe-auth/AENIU6DSPOOHGNM6HM2K5GLXFUW3PANCNFSM4G64OR3A. You are receiving this because you were mentioned.Message ID: @.***>

mnxamdev commented 1 year ago

Thanks so much @RobbiewOnline , sounds like I will need to do the same. We implemented this OnPurchaseComplete flow because there were times customers were having to leave the app to fill out some information prompted by iOS or maybe it was PayPal purchases and these purchases were getting lost on resume so we never got to send the receipt for validation to our servers.