j3k0 / cordova-plugin-purchase

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

Validator being called for every order on app start intended? #1136

Closed boost-app closed 6 months ago

boost-app commented 3 years ago

Hey! first of all thanks for the great plugin! Lifesaver

One question remains:

On app start the validator is called for every order ever placed. Is this intended or shouldn't the state of a product once verified be cached until a manual re-validation via restore purchases is triggered?

I found you mentioning something along the line of implementing a delayed verification in a different issue (see below)

Background is that i obviously want to reduce unnecessary and heavy backend calls as much as possible and this being triggered every time seems much.

Thanks!

...If you don't want your beta testers to be annoyed by this, I believe you can delay the call to product.verify(), with some custom strategy.

An example complex implementation.

store.when().approved(product => {
  MySessionManager.ready(userSession => {
    if (userSession.hasServerSideReceipt)
      return product.finish(); // no need to re-validate, we already have a receipt server-side
    if (userSession.isAnonymous)
      return product.finish(); // anonymous users cannot make purchases

    if (product.type === store.APPLICATION) {
      // this is the plugin asking if it should validate the application receipt, we don't know it server side for this user.
      needReceiptValidation = true;
    }
    else {
      // there's a regular purchase on the device, we should verify it.
      product.verify();
    }
  });
});

Originally posted by @j3k0 in https://github.com/j3k0/cordova-plugin-purchase/issues/1034#issuecomment-723594908

victorpinheiro commented 3 years ago

I have this very same issue. It seems that store.when().approved is being called every time I open the product page, which has store.get(productId). I've fixed it by changing the place I call store.get() in order to be called just once, instead of every time I open the product page.

boost-app commented 3 years ago

Are you sure that store.get() is the reason for your .approved() calls? For me it is the .refresh() which is called upon setting up the whole IAP service.

Nevertheless the underlying problem is the same: The validator is being called for EVERY purchase ever made before one can use the plugins features. Intended or shouldn't the validator state be cached once claled?

j3k0 commented 3 years ago

Hello,

Historically, Apple/Google had a single receipt for every transaction. To keep the API backward compatible, we still make a receipt validation request for each transaction.

However:

  1. If using the store.validator = "string-url"; -- the internal logic will group validation calls by product Id and only make one per product (not 1 per transaction).
  2. If you get a call for all transactions it means you are either:
    • calling store.refresh() twice (which triggers a full refresh on the second call). On iOS this will replay all and every transactions for the user)
    • not "finishing" purchases (with product.finish()), which is what removes the transactions from the transaction queue on iOS.

Note that a "buggy" implementation might have let transactions in the queue that can't easily be cleaned up: use store.autoFinishTransactions for automatic cleanup.

viktorzavadil commented 3 years ago

@j3k0 Can we detect the first success verification of the product? And what about renewal purchases? Can we detect a transaction when is called the first time?

I have noticed transaction.id and transactions fields are present in a request of receipt validator. If it's called the second time both of them are missing. Can I trust this behavior? But it's in the backend side but can I detect this state on the frontend?

Thank you very much.

boost-app commented 3 years ago

Ok im still stuck with this issue: On every app start my transactions are in the APPROVED state which triggers a validation. The validation is successful and product.finish() is called every time!

I tried store.autoFinishTransactions = true; which did not help.

store.refresh() is called only once after setup.

In my understanding the products should then be in the OWNED state upon app launch but as mentioned they are in APPROVED and trigger the whole process again.

Or am i getting this wrong for auto-renewing subscriptions, and that it is intended to have them in APPROVED on every app start so that i have to decide if i want to re-verify them?

Deeeej commented 3 years ago

I am seeing similar behavior, right after I call store.refresh() in production I soon see a call to my store.validator url (well technically two, one a http options and one a http post, as its the new CORS model in iOS) for the an old transaction/product I purchased. Its odd because regardless of the server response nothing happens, i.e. the callback for .verified() or .unverified() are not called (we pop a message on screen for either of these). Its almost like a phantom request.

[From j3k0] If using the store.validator = "string-url"; -- the internal logic will group validation calls by product Id and only make one per product (not 1 per transaction).

I originally thought this was a bug but it sounds like it is the intended behavior to call the store.validator url once, every time the app starts for each product previously purchased, even if the purchase was successful? (Or maybe I have misunderstood)

It may be just me, but I have a feeling others may be seeing this too but just not noticing in their server logs, as the code didn't change at all.

korsgaard commented 3 years ago

I'm seeing the same issue, but only on Android. All purchases are validated on every open of the app.

mifkys commented 3 years ago

Same issue. Verification of all transactions (auto-renewable, consumable) begins at app startup (after store.refresh()) on ios (sandbox). On android all ok

j3k0 commented 3 years ago

If all transactions are verified, it's probably because you're calling store.refresh() more that once. On iOS, this triggers a call to restorePurchases, the StoreKit SDK will replay all historical transactions for the user's AppStore account.

However, it's expected to check all purchases on startup (1 call per owned product, not 1 call per transaction), because by default the plugin doesn't trust the data it gets from StoreKit (patched versions of the StoreKit framework exist on rooted devices).

mifkys commented 3 years ago

No. I didn't change store initialization code for years. And now few day ago when testing new build on dev device I start receiving all consumable orders and auto renewable subscription (expired) on every app start (or resume).

My code (store initialization only in one place of app): in app.component.ts

this.platform.ready().then(() => {
    this.initStore();
});

initStore() {
    if (!this.platform.is('cordova')) {
      //console.warn('Store not initialized. Cordova is not available - Run in physical device');
      return;
    }
    if (!this.store) {
      //console.log('Store not available');
      return;
    }

    //console.log('initializing store')

    this.store.validator = `${this.api.url}/validate?${this.api.getUrlQuery()}`;

    this.store.register({
      id:    'week',
      alias: 'week',
      type:   this.store.PAID_SUBSCRIPTION
    });

    this.store.register({
      id:    'coins',
      alias: 'coins',
      type:   this.store.CONSUMABLE
    });

    this.store.when("week").updated(order => { 
      //console.log('Week', JSON.stringify(order, null, 2));
      this.setSubscriptionStatus(order.owned, 'week');
    });

    this.store.when("week").approved(order => { 
      //console.log('You just unlocked the WEEK VERSION! Verifying...');
      //console.log(order);
      this.api.loadingPresent();
      order.verify();
    });

    this.store.when("week").verified(order => {
      //console.log('WEEK verified');
      //console.log(order);
      this.setSubscriptionStatus(true, 'week');
      order.finish();
      this.api.loadingDismiss();
    });

    this.store.when("week").expired(order => {
      //console.log('WEEK expired');
      //console.log(order);
      this.setSubscriptionStatus(false);
      this.api.loadingDismiss();
    });

     this.store.when("week").unverified(order => {
      //console.log('WEEK failed to verify');
      //console.log(order);
      this.api.loadingDismiss();
    });

    this.store.when("coins").updated(order => { 
      //console.log('coins', order);
    });

    this.store.when("coins").approved(order => { 
      //console.log('You just unlocked coins! Verifying...');
      //console.log(order);
      this.api.loadingPresent();
      order.verify();
    });

    this.store.when("coins").verified(order => {
      //console.log('coins verified');
      //console.log(order);
      this.increaseBalance('coins', order);
    });

    this.store.when("coins").unverified(order => {
      //console.log('coins failed to verify');
      //console.log(order);
      this.api.loadingDismiss();
    });

    this.store.error(error => { 
      //console.log('ERROR ' + error.code + ': ' + error.message);
    });

    this.store.refresh();
  }

Here is log of item states on app start:

coins {
  "id": "coins",
  "alias": "coins",
  "type": "consumable",
  "group": null,
  "state": "registered",
  "title": "100,000 coins ",
  "description": "Increase coins balance for 100,000 coins.",
  "priceMicros": 990000,
  "price": "$0.99",
  "currency": "USD",
  "countryCode": "US",
  "loaded": false,
  "canPurchase": false,
  "owned": false,
  "introPrice": null,
  "introPriceMicros": null,
  "introPricePeriod": null,
  "introPricePeriodUnit": null,
  "introPricePaymentMode": null,
  "ineligibleForIntroPrice": null,
  "discounts": [],
  "downloading": false,
  "downloaded": false,
  "additionalData": null,
  "transaction": null,
  "billingPeriod": 0,
  "billingPeriodUnit": "Day"
}

coins {
  "id": "coins",
  "alias": "coins",
  "type": "consumable",
  "group": null,
  "state": "valid",
  "title": "100,000 coins ",
  "description": "Increase coins balance for 100,000 coins.",
  "priceMicros": 990000,
  "price": "$0.99",
  "currency": "USD",
  "countryCode": "US",
  "loaded": true,
  "canPurchase": true,
  "owned": false,
  "introPrice": null,
  "introPriceMicros": null,
  "introPricePeriod": null,
  "introPricePeriodUnit": null,
  "introPricePaymentMode": null,
  "ineligibleForIntroPrice": null,
  "discounts": [],
  "downloading": false,
  "downloaded": false,
  "additionalData": null,
  "transaction": null,
  "billingPeriod": 0,
  "billingPeriodUnit": "Day",
  "valid": true
}
coins {
  "id": "coins",
  "alias": "coins",
  "type": "consumable",
  "group": null,
  "state": "valid",
  "title": "100,000 coins ",
  "description": "Increase coins balance for 100,000 coins.",
  "priceMicros": 990000,
  "price": "$0.99",
  "currency": "USD",
  "countryCode": "US",
  "loaded": true,
  "canPurchase": true,
  "owned": false,
  "introPrice": null,
  "introPriceMicros": null,
  "introPricePeriod": null,
  "introPricePeriodUnit": null,
  "introPricePaymentMode": null,
  "ineligibleForIntroPrice": null,
  "discounts": [],
  "downloading": false,
  "downloaded": false,
  "additionalData": null,
  "transaction": null,
  "billingPeriod": 0,
  "billingPeriodUnit": "Day",
  "valid": true
}
coins approved ! Verifying...
2021-04-15 15:02:29.824043+0300 MyApp[68956:5829659] {"id":"coins","alias":"coins","type":"consumable","group":null,"state":"approved","title":"100,000 coins ","description":"Increase coins balance for 100,000 coins.","priceMicros":990000,"price":"$0.99","currency":"USD","countryCode":"US","loaded":true,"canPurchase":false,"owned":false,"introPrice":null,"introPriceMicros":null,"introPricePeriod":null,"introPricePeriodUnit":null,"introPricePaymentMode":null,"ineligibleForIntroPrice":null,"discounts":[],"downloading":false,"downloaded":false,"additionalData":null,"transaction":{"type":"ios-appstore","id":"1000000801175370","appStoreReceipt":"..."},"billingPeriod":0,"billingPeriodUnit":"Day","valid":true,"transactions":["1000000801175370"]}
coins verified
2021-04-15 15:02:33.693360+0300 MyApp[68956:5829659] {"id":"coins","alias":"coins","type":"consumable","group":null,"state":"approved","title":"100,000 coins ","description":"Increase coins balance for 100,000 coins.","priceMicros":990000,"price":"$0.99","currency":"USD","countryCode":"US","loaded":true,"canPurchase":false,"owned":false,"introPrice":null,"introPriceMicros":null,"introPricePeriod":null,"introPricePeriodUnit":null,"introPricePaymentMode":null,"ineligibleForIntroPrice":null,"discounts":[],"downloading":false,"downloaded":false,"additionalData":{},"transaction":{"type":"ios-appstore","id":"1000000801175370","appStoreReceipt":"..."},"billingPeriod":0,"billingPeriodUnit":"Day","valid":true,"transactions":["1000000801175370"]}
coins increase balance
2021-04-15 15:02:33.695400+0300 MyApp[68956:5829659] coins {
  "id": "coins",
  "alias": "coins",
  "type": "consumable",
  "group": null,
  "state": "approved",
  "title": "100,000 coins ",
  "description": "Increase coins balance for 100,000 coins.",
  "priceMicros": 990000,
  "price": "$0.99",
  "currency": "USD",
  "countryCode": "US",
  "loaded": true,
  "canPurchase": false,
  "owned": false,
  "introPrice": null,
  "introPriceMicros": null,
  "introPricePeriod": null,
  "introPricePeriodUnit": null,
  "introPricePaymentMode": null,
  "ineligibleForIntroPrice": null,
  "discounts": [],
  "downloading": false,
  "downloaded": false,
  "additionalData": {},
  "transaction": {
    "type": "ios-appstore",
    "id": "1000000801175370",
    "appStoreReceipt": "..."
  },
  "billingPeriod": 0,
  "billingPeriodUnit": "Day",
  "valid": true,
  "transactions": [
    "1000000801175370"
  ]
}
2021-04-15 15:02:34.381345+0300 MyApp[68956:5829659] coins api finished
2021-04-15 15:02:34.384548+0300 MyApp[68956:5829659] {"id":"coins","alias":"coins","type":"consumable","group":null,"state":"finished","title":"100,000 coins ","description":"Increase coins balance for 100,000 coins.","priceMicros":990000,"price":"$0.99","currency":"USD","countryCode":"US","loaded":true,"canPurchase":false,"owned":false,"introPrice":null,"introPriceMicros":null,"introPricePeriod":null,"introPricePeriodUnit":null,"introPricePaymentMode":null,"ineligibleForIntroPrice":null,"discounts":[],"downloading":false,"downloaded":false,"additionalData":{},"transaction":{"type":"ios-appstore","id":"1000000801175370","appStoreReceipt":"..."},"billingPeriod":0,"billingPeriodUnit":"Day","valid":true,"transactions":["1000000801175370"]}
2021-04-15 15:02:34.384305+0300 MyApp[68956:5829659] coins finished
2021-04-15 15:02:34.390494+0300 MyApp[68956:5829659] coins {
  "id": "coins",
  "alias": "coins",
  "type": "consumable",
  "group": null,
  "state": "valid",
  "title": "100,000 coins ",
  "description": "Increase coins balance for 100,000 coins.",
  "priceMicros": 990000,
  "price": "$0.99",
  "currency": "USD",
  "countryCode": "US",
  "loaded": true,
  "canPurchase": true,
  "owned": false,
  "introPrice": null,
  "introPriceMicros": null,
  "introPricePeriod": null,
  "introPricePeriodUnit": null,
  "introPricePaymentMode": null,
  "ineligibleForIntroPrice": null,
  "discounts": [],
  "downloading": false,
  "downloaded": false,
  "additionalData": {},
  "transaction": {
    "type": "ios-appstore",
    "id": "1000000801175370",
    "appStoreReceipt": "..."
  },
  "billingPeriod": 0,
  "billingPeriodUnit": "Day",
  "valid": true,
  "transactions": []
}
2021-04-15 15:02:34.393889+0300 MyApp[68956:5829659] coins {
  "id": "coins",
  "alias": "coins",
  "type": "consumable",
  "group": null,
  "state": "valid",
  "title": "100,000 coins ",
  "description": "Increase coins balance for 100,000 coins.",
  "priceMicros": 990000,
  "price": "$0.99",
  "currency": "USD",
  "countryCode": "US",
  "loaded": true,
  "canPurchase": true,
  "owned": false,
  "introPrice": null,
  "introPriceMicros": null,
  "introPricePeriod": null,
  "introPricePeriodUnit": null,
  "introPricePaymentMode": null,
  "ineligibleForIntroPrice": null,
  "discounts": [],
  "downloading": false,
  "downloaded": false,
  "additionalData": {},
  "transaction": {
    "type": "ios-appstore",
    "id": "1000000801175370",
    "appStoreReceipt": "..."
  },
  "billingPeriod": 0,
  "billingPeriodUnit": "Day",
  "valid": true,
  "transactions": []
}

On next app start it's repeating

mifkys commented 3 years ago

Ok. Apple just released fresh build of my app in store. I can't reproduce this bug on production build. I bought coins in the app, my balance has been increased. Then I restart my app and balance stayed the same. So bug are reproducing only on dev builds.

uncvrd commented 3 years ago

Well that's great that it works in production! If anyone can figure out how to prevent this in development, please let me know 😅

uncvrd commented 3 years ago

I think I figured out my problem! My event listeners were listening to:

InAppPurchase2.when("product").approved(approvedCallback)

instead of

InAppPurchase2.when("co.uncvrd.myapp.dev.productid").approved(approvedCallback)

Which was causing some products that weren't related (maybe by this plugin called "Application Bundle") every time I reloaded my app. I believe what I'm doing here is what you're supposed to be doing, but confused as to why "product" returns some miscellaneous products?

Anyways hope that helps!

j3k0 commented 3 years ago

On iOS, the application bundle is an implicit product. It will contain information about the download from the AppStore (first download date, first downloaded version, ...). For paid apps, this also allows checking the if the download seems legit.

"product" is a special keyword that returns all products (including the application bundle on iOS).

ipehimanshu commented 2 years ago

i have same issue yet,

can any one help me to resolve that, FYI we are using ionic 3.

Thanks in advance

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.