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

[iOS] store.refresh().finished not triggered in App Store release #1239

Open vanessag opened 2 years ago

vanessag commented 2 years ago

System Info

Ionic:

   Ionic CLI                     : 6.17.1 (/usr/local/lib/node_modules/@ionic/cli)
   Ionic Framework               : @ionic/angular 5.6.14
   @angular-devkit/build-angular : 12.2.2
   @angular-devkit/schematics    : 12.0.3
   @angular/cli                  : 12.2.2
   @ionic/angular-toolkit        : 4.0.0

Capacitor:

   Capacitor CLI      : 3.2.0
   @capacitor/android : 3.2.0
   @capacitor/core    : 3.2.0
   @capacitor/ios     : 3.2.0

Utility:

   cordova-res                          : 0.15.3
   native-run (update available: 1.4.1) : 1.4.0

System:

   NodeJS : v14.17.5 (/usr/local/Cellar/node@14/14.17.5/bin/node)
   npm    : 6.14.14
   OS     : macOS Big Sur

Expected behavior

I expect store.refresh().finished to be triggered properly.

Observed behavior

I have a 'Restore Purchases' button. When the button is pressed I display a loading indicator and then trigger a store.refresh(). I then listen for the .finished event to hide my loading indicator. However, in a version released on the App Store the .finished event never gets triggered and loading indicator gets stuck. Everything works fine in Test Flight but not on the App Store released version so this is very hard to debug. Any help would be very much appreciated!

Relevant code:

async restorePurchases() {
  this.loading = await this.loadingController.create({
    message: 'Restoring Purchases...'
  });
  await this.loading.present();

  this.store.refresh().finished(() => {
    this.ngZone.run(() => {
      this.loading.dismiss();
      if (this.subscriptionService.anyProductOwned()) {
        this.showAlert('Restore Purchases', 'Your subscription is active.');
      } else {
        this.showAlert('Restore Purchases', 'You do not have an active subscription.');
      }
    });
  });
}
vanessag commented 2 years ago

Update: I was able to reproduce the issue on a development environment by creating a new iOS sandbox tester and then click 'Restore Purchases' button and login with the new iOS sandbox tester. It seems if the user has not previously made any purchases on the account that '.finished' is never triggered.

bhaskar-se commented 2 years ago

@vanessag Hey man! have u found any solution? It seems I'm having the same issue.

vanessag commented 2 years ago

@bhaskar-se No I have no found any solution to this, unfortunately. I think this a bug with the plugin.

Maybe @j3k0 can shed some light on if this is a bug.

lquintero1979 commented 2 years ago

Hi guys, do you have any workaround for this issue? I'm having the same issue for IOS on Android it is working fine.

louisameline commented 2 years ago

Still waiting for a proper fix for this too. Note that this is a duplicate of https://github.com/j3k0/cordova-plugin-purchase/issues/1050

j3k0 commented 2 years ago

If I'm correct, on iOS, refresh.finished() will be called when the application receipt is validated. If there is no purchase, no validation take place, BUT the plugin will validate the application receipt to get the status of purchases (the pseudo-product which type is 'application' on iOS).

In case there's no validator setup, it'll use the old way of refreshing purchases provided by the StoreKit SDK, which is to replay all transactions. However, this method has no real "DONE" event, to refresh.finished() might not trigger (I need to check).

A simple fix should be to make sure you validate application receipts in your application.

louisameline commented 1 year ago

Thanks for the answer. Not sure I fully understand it though.

My current workaround is to trigger my finished callback when enough loaded events have been fired. Edit: this workaround doesn't actually work as expected but I found a better one, see my next messages. My full code is below. Thank you!

// a global variable created by the cordova plugin
this.purchases.store = window.store
this.purchases.store.validator = config.storeValidator

// 'product' matches both products and subscriptions.
this.purchases.store.when('product')
    // approved and verified are the only really required listeners as they handle
    // subscription
    .approved(product => {
        product.verify()
    })
    .verified(product => {
        product.finish()
    })
    // `updated` is called several times for the same
    // product at initialization, once for every step of the life cycle
    // https://github.com/j3k0/cordova-plugin-purchase/blob/master/doc/api.md#life-cycle),
    // and we can't trust its data as only the last `updated` event we get will
    // have the right data. So we only take into account the `updated` events
    // that might happen after initialization.
    .updated(product => {
        if(product.state !== 'registered' && product.alias !== 'application') {
            this.purchasesProductUpdated(product)
        }
    })
    // `loaded` happens when we have the product info, but it doesn't tell us
    // if the user owns it yet (it seems that its `state` and `owned` properties are
    // always 'valid' and `false`). We use the `finished` event
    // of the store refresh instead further below, which does happen once we have
    // what we need (currently not working on iOS though)
    .loaded(product => {
        // iOS bug fix, since `finished` is never triggered on iOS, we need to trigger
        // it ourselves once we got this number of products
        if (this.cordova.device.platform === 'iOS') {
            loadedProductsCount++
            if (loadedProductsCount === Object.keys(config.offer.product).length) {
                setTimeout(() => this.purchasesRefreshFinished(), 500)
            }
        }
    })

this.purchases.store.error(error => {

    /*
    switch (error.code) {
        case 6777002: // server unreachable. will try to reach again in a few moments
        case 3777003: // ITEM_ALREADY_PURCHASED
    }
    */

    this.syslog('warning', new Error('APPLICATION STORE ERROR ' + error.code + ': ' + error.message))
})

// register products/subscriptions
const products = new Array
Object.entries(config.offer.product).forEach(entry => {
    products.push({
        id: entry[1].productId,
        type: this.purchases.store[entry[1].productType],
        alias: entry[0]
    })
})
this.purchases.store.register(products)

const refresh = this.purchases.store.refresh()

// `failed` seems useless as the lib will make new attempts later and errors
// are caught by our error callback anyway
// refresh.failed(() => {})

// never called on iOS after initial load
refresh.finished(this.purchasesRefreshFinished)
louisameline commented 1 year ago

Update: interestingly, when clicking the Refresh Purchase button for the first time, the finished event is actually fired, but twice! And if I click on it again, the next times it's fired only once.

So I guess the initial finished event of the initial refresh that we all want in this thread is somehow "queued" and is fired only when a second refresh happens.

It would be great if you could take a look. Thank you

louisameline commented 1 year ago

Lol, thanks to this discovery, I stopped using the loaded event as my workaround (it didn't fully work anyway) and I wrote a new workaround which actually works now: refresh twice in a row at start, and it actually has the expected result: the finished event is triggered once, and subsequent calls to refresh() also trigger a single finished event.

const refresh = store.refresh()
if (cordova.device.platform === 'iOS') {
  store.refresh()
}

Jeez, maybe Apple will publish my app now :/

fieres commented 1 year ago

The second call to "refresh" might prompt for the login to the app store which is not a nice startup experience. As I understand the second "refresh" will automatically try to restore previous purchases.