j3k0 / cordova-plugin-purchase

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

Store never ready if app is on expired receipt state. #1055

Open lalmanzar opened 4 years ago

lalmanzar commented 4 years ago

https://github.com/j3k0/cordova-plugin-purchase/blob/8861bd2392a48d643ffc754b8f59afc1e6afab60/src/js/platforms/ios-adapter.js#L286

After version 10.0.0 this line changed and now the isReady variable only is set after the 'verified' or 'unverified' events are triggered for the appstore receipt. The issue is that if the receipt validator returns that the receipt is expired, the events 'verified' or 'unverified' are never triggered, the event triggered is 'expired'. This causes that the store.update callbacks are never called.

stolzda commented 4 years ago

I had a similar problem. I also noticed that the iOS store was just stuck and the event was never called, and that you need to verify all products. How do you mark the products as expired? I do it currently like this

store.validator = function (product, callback) {

  // TODO validate

  if (expired) {
    callback(false, {code: store.PURCHASE_EXPIRED});
  } else {
    callback(true, {});
  }
}

Which works for me now.

lalmanzar commented 4 years ago

I don't use a custom validator, I just provide the URL for the validator. When the product is expired it returns:

{
    ok: false,
    data: {
        code: 6778003,
        expiryDate: //someDate,
        ...
    }
}
stolzda commented 4 years ago

Ok. Not sure if this is the same issue which I had - I would assume that this validator works, but I dont use it, so I'm not sure.

However, my Issue was that I didn't call verify on all products (in particular, the stupid app product, which is automatically addd by Apple).

In particular I had to do this:

store.when('product').approved(function (product) {
  // XXX We always need to call verify, because some lifecycle hooks do not work properly if we don't and
  // call finish here directly.

  product.verify();
});

And not call product.finish() directly in this function (which I did for the app products).

I then wrote my validator in such a way, that I only validate the products which I care about. For all the others I call callback(true, {}); directly in the store.validator function.

Maybe you're issue is the same?

lalmanzar commented 4 years ago

Doesn't seems to be my issue. I also call verify on the approved hook. And all verifications are done. Every product ends up on valid state, but store.ready is never set to true and the ready event is not fired. and because of that, I cannot perform any order.

The debug logs shows that the verify is executed, but like I said, if the product is expired, store.update never sets the isReady to true. This issue is only on iOS since on android the update calls iabGetPurchases where store.ready(true) is called.

[Log] [store.js] DEBUG: runValidation() (cordova.js, line 1509)
[Log] [store.js] DEBUG: ajax -> send request to http://***/mobile/v13/user/check-purchase (cordova.js, line 1509)
[Log] [store.js] DEBUG: validator success, response: {"ok":false,"data":{"code":6778003,"expiryDate":1558069064000,"productId":"MONTHLY_01_35"}} (cordova.js, line 1509)
[Log] [store.js] DEBUG: verify -> {"success":false,"data":{"code":6778003,"expiryDate":1558069064000,"productId":"MONTHLY_01_35"}} (cordova.js, line 1509)
[Log] [store.js] DEBUG: expiryDate: 2019-05-17T04:57:44.000Z (cordova.js, line 1509)
[Log] [store.js] DEBUG: verify -> error: {"code":6778003,"expiryDate":1558069064000,"productId":"MONTHLY_01_35"} (cordova.js, line 1509)
[Log] [store.js] DEBUG: ios -> product net.bookedin.bam expired (cordova.js, line 1509)
[Log] [store.js] DEBUG: ios -> product net.bookedin.bam owned=false (cordova.js, line 1509)
[Log] [store.js] DEBUG: state: net.bookedin.bam -> valid (cordova.js, line 1509)
lonwi commented 4 years ago

This is a big issue that stops users from resubscribing. I need to test it with the "official" validator.

rdlabo commented 4 years ago

+1. I have same issue:

⚡️  [log] - [store.js] DEBUG: verify -> {"success":false,"data":{"code":6778003}}
⚡️  [log] - [store.js] DEBUG: verify -> error: {"code":6778003}
⚡️  [log] - [store.js] DEBUG: ios -> product jp.rdlabo.tipsys expired
⚡️  [log] - [store.js] DEBUG: ios -> product jp.rdlabo.tipsys owned=false
⚡️  [log] - [store.js] DEBUG: state: jp.rdlabo.tipsys -> valid
Merwan1010 commented 4 years ago

I encountered the same issue.

When verifying the subscription with a custom validator, the store.ready function won't be triggered if the product expired.

I tried using fovea.cc validator to see if the problem still remain, but no. With Fovea everything work fine, store.ready get triggered and we can make a purchase.

After analyzing the answer from Fovea API, I just copy/paste the JSON result and put in my custom validator (function or backend) and it also worked.

Here is what the JSON from the Fovea API looks like :

{
   "success":true,
   "data":{
      "id":"com.myBundleId",
      "ineligible_for_intro_price":[
         "premium.membership"
      ],
      "latest_receipt":true,
      "transaction":{
         "receipt_type":"ProductionSandbox",
         "adam_id":0,
         "app_item_id":0,
         "bundle_id":"com.myBundleId",
         "application_version":"0.0.1",
         "download_id":0,
         "version_external_identifier":0,
         "receipt_creation_date":"2020-07-24 09:15:49 Etc/GMT",
         "receipt_creation_date_ms":"1595582149000",
         "receipt_creation_date_pst":"2020-07-24 02:15:49 America/Los_Angeles",
         "request_date":"2020-07-24 09:18:07 Etc/GMT",
         "request_date_ms":"1595582287004",
         "request_date_pst":"2020-07-24 02:18:07 America/Los_Angeles",
         "original_purchase_date":"2013-08-01 07:00:00 Etc/GMT",
         "original_purchase_date_ms":"1375340400000",
         "original_purchase_date_pst":"2013-08-01 00:00:00 America/Los_Angeles",
         "original_application_version":"1.0",
         "in_app":[
            {
               "quantity":"1",
               "product_id":"premium.membership",
               "transaction_id":"1000000695192040",
               "original_transaction_id":"1000000674665554",
               "purchase_date":"2020-06-04 21:33:42 Etc/GMT",
               "purchase_date_ms":"1591306422000",
               "purchase_date_pst":"2020-06-04 14:33:42 America/Los_Angeles",
               "original_purchase_date":"2020-07-20 11:20:56 Etc/GMT",
               "original_purchase_date_ms":"1595244056000",
               "original_purchase_date_pst":"2020-07-20 04:20:56 America/Los_Angeles",
               "expires_date":"2020-06-04 21:38:42 Etc/GMT",
               "expires_date_ms":"1591306722000",
               "expires_date_pst":"2020-06-04 14:38:42 America/Los_Angeles",
               "web_order_line_item_id":"1000000053043630",
               "is_trial_period":"false",
               "is_in_intro_offer_period":"false",
               "subscription_group_identifier":"20643493"
            },
            {
               "quantity":"1",
               "product_id":"premium.membership",
               "transaction_id":"1000000695192048",
               "original_transaction_id":"1000000674665554",
               "purchase_date":"2020-06-04 21:23:42 Etc/GMT",
               "purchase_date_ms":"1591305822000",
               "purchase_date_pst":"2020-06-04 14:23:42 America/Los_Angeles",
               "original_purchase_date":"2020-07-20 11:20:56 Etc/GMT",
               "original_purchase_date_ms":"1595244056000",
               "original_purchase_date_pst":"2020-07-20 04:20:56 America/Los_Angeles",
               "expires_date":"2020-06-04 21:28:42 Etc/GMT",
               "expires_date_ms":"1591306122000",
               "expires_date_pst":"2020-06-04 14:28:42 America/Los_Angeles",
               "web_order_line_item_id":"1000000053043471",
               "is_trial_period":"false",
               "is_in_intro_offer_period":"false",
               "subscription_group_identifier":"20643493"
            }
         ]
      },
      "collection":[
         {
            "id":"premium.membership",
            "isBillingRetryPeriod":false,
            "isTrialPeriod":false,
            "isIntroPeriod":false,
            "renewalIntent":"Lapse",
            "purchaseDate":1595244140000,
            "expiryDate":1595221400000,
            "cancelationReason":"Customer",
            "lastRenewalDate":1595221100000,
            "isExpired":true
         }
      ]
   }
}

For testing you can write your validator custom function like this :

this.store.validator =  (product: IAPProduct, callback) =>{
callback(true, this.FoveaApiJSON);
}

Surprisingly, no matter if the subscription expired or not, Fovea seems to trigger the callback with true, as we can read on the DEBUG logs after the arrow -> :

[store.js] DEBUG: verify -> success: {"success":true, "data":{"transaction":{"receipt_type":"ProductionSandbox", (...)

(if the callback is false the console display verify -> error)

Hence, the plugin seems to understand by himself (by reading the object) if the subscription expired or not.

I'm actually trying to find which fields are mandatory and which one are optional. I will update this message when i will have more answer.

Merwan1010 commented 4 years ago

Okay, Since it seems to be working using Fovea, i just decided to make a clone of this one. However, i noticed that the field req.body.receipt.in_app from the Apple request that i get is different from the one Foreva show :

Screen Shot 2020-07-25 at 20 28 20

On the left is the object i get , on the right is what Foreva API is answering to the plugin. This is a mystery for me, if anyone have an idea why please let me know.

Anyway i just made an ugly script in my back end for making an API that answer exactly like Foreva, this seems to be working but buggy sometimes : after subscribing, around 30 seconds later the plugin will call the validator one more time (why ?) Even if the JSON i send is almost exactly like the one Foreva provide.

Here is the code, please try it on your device, feel free to improve it and give me feedback.

app.post('/receiptValidator', (req,res) => {
    let IAPBody = req.body;
    if (IAPBody.transaction) {
        if (IAPBody.transaction.type === 'ios-appstore') appleVerifyReceipt(IAPBody.transaction.appStoreReceipt).then(appleBody => {
            if (appleBody.status === 21002) res.status(200).json({ok:false, status : 21002, message : appleBody.exception});
            else {
                let myJSON = {};
                if ((new Date(parseInt(appleBody.latest_receipt_info[0].expires_date_ms)) < new Date()) && IAPBody.type !== 'application') { //if expired
                    appleBody.latest_receipt_info[0].type = IAPBody.transaction.type;
                    myJSON = {
                        "ok":false,
                        "route":"/v1/validate",
                        "status":419,
                        "code":6778003,
                        "message": `Transaction has expired ${appleBody.latest_receipt_info[0].expires_date}`,
                        "data":{
                            "code":6778003,
                            "error":{
                                "message":`Transaction has expired ${appleBody.latest_receipt_info[0].expires_date}`
                            },
                            "transaction": appleBody.latest_receipt_info[0],
                            "latest_receipt":true
                        }
                    }
                }
                else {
                    myJSON ={
                        ok : true,
                        data : {
                            id : IAPBody.id,
                            ineligible_for_intro_price : [],
                            latest_receipt : true,
                            transaction : IAPBody.type === 'application' ? appleBody.receipt : appleBody.latest_receipt_info[0],
                            collection : [
                                {
                                    id : appleBody.latest_receipt_info[0].product_id,
                                    isBillingRetryPeriod : (appleBody.pending_renewal_info[0].is_in_billing_retry_period === '1'),
                                    isTrialPeriod: (appleBody.latest_receipt_info[0].is_trial_period === 'true'),
                                    isIntroPeriod: (appleBody.latest_receipt_info[0].is_in_intro_offer_period === 'true'),
                                    renewalIntent: (appleBody.pending_renewal_info[0].auto_renew_status === '0' ? 'Lapse' : 'Renew'),
                                    purchaseDate : parseInt(appleBody.latest_receipt_info[0].original_purchase_date_ms),
                                    expiryDate : parseInt(appleBody.latest_receipt_info[0].expires_date_ms),
                                    cancelationReason:"Customer",
                                    lastRenewalDate: parseInt(appleBody.latest_receipt_info[0].purchase_date_ms) || appleBody.pending_renewal_info[0].purchase_date_ms,
                                    isExpired : (new Date(parseInt(appleBody.latest_receipt_info[0].expires_date_ms)) < new Date())
                                }
                            ]
                        }
                    };

                    if (!myJSON.data.collection[0].isExpired) { //if trying to subscribe
                        myJSON.data.transaction.type = IAPBody.transaction.type;
                        if (IAPBody.type !== 'application') delete myJSON.data.ineligible_for_intro_price;
                        delete myJSON.data.collection[0].isBillingRetryPeriod;
                        delete myJSON.data.collection[0].cancelationReason;
                    }
                }

                if (myJSON.data.transaction.in_app) { //foeva is creating the field "subscription_group_identifier" on every app object;
                    myJSON.data.transaction.in_app.forEach(appObj => {
                        appObj.subscription_group_identifier = appleBody.latest_receipt_info[0].subscription_group_identifier;
                        if (appObj.is_trial_period === 'true') myJSON.data.ineligible_for_intro_price = [appObj.product_id]
                    });
                    myJSON.data.transaction.in_app = myJSON.data.transaction.in_app.sort((a,b) => {return parseInt(a.original_transaction_id) - parseInt(b.original_transaction_id)}); //in_app array is sorted by original_transaction_id on Fovea
                }
                res.status(200).json(myJSON);
            }
        }).catch(err => {console.error('appleVerifyReceipt error : ', err)})
    }
});
rdlabo commented 3 years ago

I understood! validator require this format, when expired:

const data = {
        ok: true,
        data: {
          collection: [
            {
              expiryDate: 1603980574000,
              isExpired: true,
            },
          ],
        },
      };
pschinis commented 3 years ago

Maybe someone can tell me why this is a bad idea but I just put

store.once("product").expired(prod => store.ready(true))

in my initialization code and that seems to work for my setup. I know store.ready(true) is only meant for internal use but when the internals are broken ¯\_(ツ)_/¯

trungdv2nf commented 3 years ago

@MadyAkira your save my life ✌ it should be noted in API documentation

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