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

CdvPurchase.store.owned and Product.owned always returns false with android subscription product #1443

Closed rodolfocondemx closed 6 months ago

rodolfocondemx commented 9 months ago

Observed behavior

In android platform, the CdvPurchase.store.owned and Product.owned method/property always return false, even after the standard purchase cycle approved => verify (with REST server) => finish. I refresh the store with CdvPurchase.store.update() after finishing transaction but the result is the same.

Expected behavior

The owned method and property should return true after the purchase cycle has finished successfully.

System Info

neonish commented 9 months ago

I am experiencing the same on iOS (sandbox user on a real device)!

My purchase cycle is pretty much the same as here, but with more logging. The product type is ProductType.PAID_SUBSCRIPTION.

This is what I can see in my logs:

  1. Purchase is made
  2. when().approved() is fired
  3. transaction.verify() gets called
  4. The validator's callback is called with SuccessPayload via my custom validator function
  5. when().verified() is fired
    • the transaction's state is finished here
    • in receipt.nativeTransactions the corresponding in_app entry has is_trial_period = true
  6. receipt.finish() gets called
  7. when().finished() is fired

At this point I can verify via my app store's sandbox user that the subscription is active.

In every step of the purchase cycle I check the owned state with CdvPurchase.store.owned(product), CdvPurchase.store.owned(productId) or product.owned, but it is never true. Not even after calling CdvPurchase.store.update().

The receiptUpdated() and productUpdated() methods are firing throughout the entire process but owned is never true here either, even if the logs show that the transaction's state is finished here.

No errors / exceptions can be seen in the log.

@j3k0 can you verify if this is the intended behavior? What are we doing wrong?

uKioops commented 9 months ago

I am trying to find a way too for that. The thing is, subscription with iOS and Android work differently.

For example a subscription with 2 plans : monthly / yearly.

On iOS product.owned turn true after validation, when you purchase the subscription, as intended. On Android it works the same way only if you have a subscription with only one offer.

The problem is if you have two subscriptions with one plan on android, user can subscribe to both of them at the same time ! (There is an API that you can call to cancel a subscription, so it should be possible to cover that on the backend but it's not the best).

The problem is when a product has more than one offer, product.owned doesn't work or at least not as easily as with a product with one offer. I didn't find how to make it work for the moment.

I'll follow here and update if I can find something.

neonish commented 9 months ago

@uKioops are you using a custom receipt validation or iaptic?

uKioops commented 9 months ago

@neonish We are using custom validation with a java server, but with iaptic it's the same. Android subscription with more than one offer doesn't trigger product.owned to true.

neonish commented 9 months ago

@uKioops if product.owned works for you on iOS, would you mind sharing your workflow?

uKioops commented 9 months ago

@uKioops if product.owned works for you on iOS, would you mind sharing your workflow?

No problem, I'm using angular 15,Ionic 6, capacitor 5 and the v13 of the cordova-plugin-purchase. I think the important thing to update the template for iOS is the ref.detectorChange. I'm working on a method to check if an offer is owned (it's half working at the moment ! It doesn't update the template properly.)

My code is full of console.log to check everything. Also it might not be the best in terms of optimization / good writing but it seems to work ! I also remove a lot of loading Ctrl.

TS file

import 'cordova-plugin-purchase';
import 'cordova-plugin-purchase/www/store.d';

export class subComponent implements OnInit, OnDestroy {

  myAboGoogle: Array<CdvPurchase.Offer> = [];
  myAboApple: Array<CdvPurchase.Product> = [];

  constructor(
    private modalCtrl: ModalController,
    private alertCtrl: AlertController,
    private plt: Platform,
    private ref: ChangeDetectorRef,
    private toastCtrl: ToastController,
    private loadingCtrl: LoadingController,
  ) {}

  ngOnInit(): void {

    this.plt.ready().then(() => {
      const store: CdvPurchase.Store = new window.CdvPurchase.Store();
      console.log(store)
      store.error( (error) => {
        console.log('ERROR ' + error.code + ': ' + error.message);
      });

      store.ready( () => {
        console.log('CdvPurchase is ready');

      });

      });

  }

  ionViewWillEnter() {
    this.registerAbo();

  }

  registerAbo() {
    console.log('register abo');
    const { store, ProductType, Platform, AppleAppStore } = CdvPurchase;

    // const iaptic = new CdvPurchase.Iaptic({
    //   appName: "YOUR_APP_NAME",
    //   apiKey: "YOUR_KEY",
    // });

    // for the backend, we send the userId with this method
    store.applicationUsername = () => "USER_ID";

    // store.validator = iaptic.validator;
    if(this.plt.is('ios')) {
      store.validator = "MY_CUSTOM_APPLE_VALIDATOR_URL";
    }

    if(this.plt.is('android')) {
      store.validator = "MY_CUSTOM_ANDROID_VALIDATOR_URL";
    }

    store.verbosity = 4;
    store.register([
      {
        id: SUB_YEAR_IOS_ID,
        type: CdvPurchase.ProductType.PAID_SUBSCRIPTION,
        platform: Platform.APPLE_APPSTORE,

      },
      {
        id: SUB_MONTH_OS_ID,
        type: CdvPurchase.ProductType.PAID_SUBSCRIPTION,

        platform: Platform.APPLE_APPSTORE,
      },

      {
        id: SUB_ANDROID,
        type: CdvPurchase.ProductType.PAID_SUBSCRIPTION,
        platform: Platform.GOOGLE_PLAY,
      },

       {
      id: 'test-subscription',
      type: CdvPurchase.ProductType.PAID_SUBSCRIPTION,
      platform: Platform.TEST
       }

    ]);

    if(store.isReady) {
      this.updateStore();
    } else {
      if (this.plt.is('ios') && !store.isReady) {
        console.log('init apple');
        store.initialize([{ platform: Platform.APPLE_APPSTORE }]);
      } else if (this.plt.is('android') && !store.isReady) {
        console.log('init google');
        store.initialize([{ platform: Platform.GOOGLE_PLAY }]);
      } else {
        console.log('not mobile');
        if(!store.isReady) {
          store.initialize([{platform: Platform.TEST}])
        }

      }
    }

    store.when()
    .productUpdated(product => {

      if(this.plt.is('android')) {
        this.myAboGoogle = store.products[0].offers
      } else {
        this.myAboApple = store.products
      }
      //this is needed to refresh the template on update, without it it doesn't work
      this.ref.detectChanges();
    })
    .approved(transaction => {
      console.log('transaction happening');

      const monitor = store.monitor(transaction, state => {
        console.log('new State: ' + state);
        if(state === 'approved') {
          console.log(transaction.state)
        }
        if (state === 'finished') {
          console.log(transaction.state)
          monitor.stop();
        }
      })
      console.log('transaction verify trigger')
      console.log(transaction.verify)
      transaction.verify().then(() => {
        this.toasterOrderState('Verification happening');
      })
    })
    .pending(transaction => {
      console.log('transaction pending');
      console.log(transaction);
    })
    .verified(receipt => {
      this.toasterOrderState('Sub verified');
      receipt.finish().then(() => {
        this.toasterOrderState('Verification done');

        if(this.plt.is('android')) {
          this.myAboGoogle = store.products[0].offers

        } else {
          this.myAboApple = store.products
        }

        this.ref.detectChanges();
      })
    })
    .unverified(receipt => {
      console.log('unverified payload')
      // console.log(receipt.payload);
      if(receipt.payload.ok === false) {
        this.errorToaster("Verification failed " + receipt.payload.message)
      }
      console.log('unverified receipt')
      console.log(receipt.receipt);
    })
    .receiptUpdated((receipt) => {
      // console.log('Receipt Update')
      // console.log(receipt)

      if(this.plt.is('android')) {
        this.myAboGoogle = store.products[0].offers
        console.log('verified purchases')
        console.log(store.verifiedPurchases);
      } else {
        this.myAboApple = store.products
      }
      console.log(this.myAboGoogle);
      this.ref.detectChanges();
    })

    store.error(error =>  {
     console.log('Store Error ' + error.code + ' ' + error.message + ' ' + error.isError )
     this.errorToaster(error.message);

    });

  }

 async updateStore() {
    const { store, ProductType, Platform } = CdvPurchase;

    store.update().then(() => {
      console.log('store update done')
    });
  }

//for Apple Owned state
  isOwned(product: string, plt: CdvPurchase.Platform) {
    const { store} = CdvPurchase;

     return store.owned({id: product, platform: plt});

  }

  androidSubOwned(tag: string) {
    // working on the custom owned state for android

  }

 // I'm showing an alert on click to purchase the sub
  async getAbo(abo: CdvPurchase.Product) {
    const alert = await this.alertCtrl.create({
      header: 'Warning',
      message:
        'Alert text',
      cssClass: 'ion-text-left',
      inputs: [{ type: 'checkbox', label: "ok", checked: false, value: true,}],
      buttons: [
        {
          text: 'Cancel',
          role: 'cancel',
        },
        {
          text: "Sub",
          role: 'confirm',
          handler: (inputData) => {

            if(inputData[0] === true) {

               if(this.plt.is('ios') === true ) {
                // I put a loader for apple because the storeKit is long to show
              }
               abo.getOffer()?.order().then((order: any) => {
                console.log(order)
                console.log('success order')

               })
              return true
            }
            return false

          }
        },
      ],
    });
    if(!this.isOwned(abo.id, abo.platform)) {
      alert.present();
    }

  }

  async getAboGoogle(abo: CdvPurchase.Offer) {
    const alert = await this.alertCtrl.create({
      header: 'Warning',
      message:
        'Alert message',
      cssClass: 'ion-text-left',
      inputs: [{ type: 'checkbox', label: "Ok, checked: false, value: true,}],
      buttons: [
        {
          text: 'Cancel',
          role: 'cancel',
        },
        {
          text: "Sub",
          role: 'confirm',
          handler: (inputData) => {

            if(inputData[0] === true) {

               abo.order().then((order: any) => {
                console.log(order)
                console.log('success order')

               })
              return true
            }
            return false

          }
        },
      ],
    });

      alert.present();

  }

  private toasterOrderState(orderState: string) {
    this.toastCtrl.create({
      header: "State of sub",
      icon: 'information-circle-outline',
      position: 'top',
      message: orderState,
      color: 'success',
      duration: 2500,
    }).then(toast => {
      toast.present();
    });
  }

  private errorToaster(errorMessage: string){
    this.toastCtrl.create({
      header: "Error happening",
      icon: 'warning',
      position: 'bottom',
      message: errorMessage,
      color: 'danger',
      duration: 5000,
    }).then(toast => {
      toast.present();
    });
  }

  ngOnDestroy(): void {
    console.log('on destroy');

  }

}

HTML file

        <ng-container *ngIf="myAboApple.length > 0" >
          <ng-container *ngFor="let abo of myAboApple">
          <ion-item *ngIf="isOwned(abo.id, abo.platform)" lines="none"><ion-label><h1>Sub owned</h1></ion-label></ion-item>
        <ion-card (click)="getAbo(abo)" >

          <div class="text-abo">
            <h2>{{abo.pricing?.price}}</h2>
            <ul>

              <li>{{abo.description}}</li>
            </ul>
          </div>

          <ion-item color="medium" class="ion-text-center" *ngIf="!isOwned(abo.id, abo.platform)">
            <ion-label class="ion-text-wrap" color="white">Purchase<sup>**  </sup></ion-label>
          </ion-item>

        </ion-card>
      </ng-container>
      </ng-container>
uKioops commented 9 months ago

I've just realised that the product.owned for android and iOS will not be a good fit for my app, as we want people who have a subscription purchased on iOS should be able to use our service on Android too. We'll use data from backend to allow sub and show the plan purchased.

But I'll share what I was trying, it might help you.

The plugin method isOwned is using verifiedreceipt to check for the expiryDate and compare it to the actual date. I think it extract it from the collection of the receipt.

from the plugin

    /** Return true if a product is owned, based on the content of the list of verified receipts  */
      static isOwned(verifiedReceipts: VerifiedReceipt[], product?: { id: string; platform?: Platform }) {
        if (!product) return false;
        const purchase = VerifiedReceipts.find(verifiedReceipts, product);
        if (!purchase) return false;
        if (purchase?.isExpired) return false;
        if (purchase?.expiryDate) {
          return (purchase.expiryDate > +new Date());
        }
        return true;
      }

With android subscription with multiple offer, the problem (I think, not entirely sure) the verified receipt collection is returning an expiryDate of 0, so it's always false.

Example of verifiedReceipt from an android subscription with multiple offers

"collection":[
{
"id":"android_sub_id",
"expiryDate":0,
"isBillingRetryPeriod":false,
"renewalIntent":null,
"purchaseDate":0,
"isExpired":false,
"billingRetryPeriod":false,
"expired":false
}
],

But you can find all the info in the nativeTransactions part of the receipt, and also the tags of the offer purchased.


"nativeTransactions":[
{
"acknowledgementState":"ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED",
"kind":"androidpublisher#subscriptionPurchaseV2",
"latestOrderId":"...order_id...",
"lineItems":[
{
"autoRenewingPlan":{
"autoRenewEnabled":true
},
"expiryTime":"2023-08-01T07:51:41.480Z",
"offerDetails":{
"basePlanId":"yearly-sub",
"offerTags":[
"yearly"
]
},
"productId":"android_sub_id"
}
],
"linkedPurchaseToken":"...TOKEN...",
"regionCode":"FR",
"startTime":"2023-08-01T07:19:56.702Z",
"subscriptionState":"SUBSCRIPTION_STATE_ACTIVE",
"testPurchase":{
}
}
]

The problem is to access everything, because of the type restriction and model in place. But I tried to use the "raw" part of the receipt and avoid the types for that. Inside I find the tag and expiryTime. To access the tag inside the HTML, you'll need to modify the offer class inside store.d.ts to add tags: string[];

With that I did this method :

  androidSubOwned(tag: string) {
    const {store} = CdvPurchase
    const verifiedReceipt = store.verifiedReceipts;
    let expire: number = 0;
    let aboTag: string = '';

    for(let receipt of verifiedReceipt) {

      const transaction: any = receipt.raw.transaction
      console.log(transaction);
      expire = +new Date(transaction.lineItems[0].expiryTime);
      aboTag = transaction.lineItems[0].offerDetails.offerTags[0]

    }

    return expire > +new Date() && aboTag === tag;

  }

template

      <ng-container *ngIf="myAboGoogle.length > 0" >
        <ng-container *ngFor="let abo of myAboGoogle">
        <ion-item *ngIf="androidSubOwned(abo.tags[0])" lines="none"><ion-label><h1>Sub owned</h1></ion-label></ion-item>
      <ion-card class="card-abonnement"   (click)="getAboGoogle(abo)" >
         <h2>{{abo.pricingPhases[0].price}} </h2>
        <ion-item color="medium" class="ion-text-center" *ngIf="!androidSubOwned(abo.tags[0])">
          <ion-label class="ion-text-wrap" color="white">Purchase<sup>**  </sup></ion-label>
        </ion-item>

      </ion-card>

    </ng-container>
    </ng-container>

The problem is when the expiryTime come and the new receipt is not checked, the owned state is false, and you need to restart the app so it gets checked again. So it needs something to force the recheck. You can look into the verified-receipt.ts of the plugin, might get inspired.

I might be searching that on my free time, but as I said, I won't use it in the end so for the moment I leave it here.

neonish commented 9 months ago

Fantastic, thanks heaps for the large amounts of input. I'll go through all of it asap.

sajjadalis commented 9 months ago

Android subscription with more than one offer doesn't trigger product.owned to true.

@uKioops I have tried a subscription with single base plan and product.owned still returns false. I'm using v13 of this plugin.

But you can find all the info in the nativeTransactions part of the receipt, and also the tags of the offer purchased.


"nativeTransactions":[
{
"acknowledgementState":"ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED",
"kind":"androidpublisher#subscriptionPurchaseV2",
"latestOrderId":"...order_id...",
"lineItems":[
{
"autoRenewingPlan":{
"autoRenewEnabled":true
},
"expiryTime":"2023-08-01T07:51:41.480Z",
"offerDetails":{
"basePlanId":"yearly-sub",
"offerTags":[
"yearly"
]
},
"productId":"android_sub_id"
}
],
"linkedPurchaseToken":"...TOKEN...",
"regionCode":"FR",
"startTime":"2023-08-01T07:19:56.702Z",
"subscriptionState":"SUBSCRIPTION_STATE_ACTIVE",
"testPurchase":{
}
}
]

I'm not getting lineItems and offerDetails and other stuff in nativeTransactions array. Tried with 2 different subscription, single base plan and multiple base plans. This is what I'm getting.

"nativeTransactions": [{
    "orderId": "GPA.0601-3478-75916",
    "packageName": "app.example.app",
    "productId": "premiumsub",
    "purchaseTime": 1691065236535,
    "purchaseState": 0,
    "purchaseToken": "TOKEN",
    "quantity": 1,
    "autoRenewing": true,
    "acknowledged": false,
    "startTimeMillis": "1691065236535",
    "expiryTimeMillis": "1691065532849",
    "priceCurrencyCode": "PKR",
    "priceAmountMicros": "430000000",
    "countryCode": "PK",
    "developerPayload": "",
    "paymentState": 1,
    "purchaseType": 0,
    "acknowledgementState": 0,
    "kind": "androidpublisher#subscriptionPurchase",
    "type": "android-playstore",
    "id": "GPA.0601-3478-75916"
}]

In your receipt "kind":"androidpublisher#subscriptionPurchaseV2", while I have "kind": "androidpublisher#subscriptionPurchase",.

Any idea why I'm getting different kind of transaction array from store.verifiedReceipts? I'm using Billing Library 5.0

Thank you for your help on this issue.

uKioops commented 9 months ago

@sajjadalis do you use custom validation server or iaptic ?

maybe try to use v6 (https://developer.android.com/google/play/billing/migrate-gpblv6)

so you can modify your endpoint for verification to purchases.subscriptionsv2.get (https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.subscriptionsv2/get?hl=fr) that will send you the v2 of the receipt with the lineItems field (https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.subscriptionsv2?hl=fr#resource:-subscriptionpurchasev2)

j3k0 commented 6 months ago

With subscriptions, when using a validator, owned will be computed from "verified purchases" which is defined in your response of the receipt validation request. It works with https://iaptic.com -- should be an issue with a custom receipt validation endpoint.

rodolfocondemx commented 6 months ago

@j3k0 did you really mean "should be an issue" or was it "should not be an issue" with a custom receipt validation endpoint?

j3k0 commented 6 months ago

I really mean "should", as in: "that's probably an issue with a custom receipt validation service" (assuming you are not using iaptic).

rubanraj7 commented 6 months ago

What are you returning from the "MY_CUSTOM_ANDROID_VALIDATOR_URL" ?

uKioops commented 6 months ago

What are you returning from the "MY_CUSTOM_ANDROID_VALIDATOR_URL" ?

it's the url of our custom validation API that we built on our server. But if you can use IAPTIC, use it really, it took us 2 or 3 weeks to make it work, but we needed to do it.

rubanraj7 commented 6 months ago

What are you returning from the "MY_CUSTOM_ANDROID_VALIDATOR_URL" ?

it's the url of our custom validation API that we built on our server. But if you can use IAPTIC, use it really, it took us 2 or 3 weeks to make it work, but we needed to do it.

So I got it to work. My server needs to return the following for the validator to pick up the verification. return response()->json(["ok" => true, "data"=>[]], 200);

rodolfocondemx commented 6 months ago

In my case, for Android, i have finally solved my issue by returning the verified purchase collection of the data field returned by the verification server, which makes owned work as expected. We will see how it goes when i get to the iOS case. Thanks @j3k0 and everyone for your comments

bboldi commented 6 months ago

In my case, for Android, i have finally solved my issue by returning the verified purchase collection of the data field returned by the verification server, which makes owned work as expected. We will see how it goes when i get to the iOS case. Thanks @j3k0 and everyone for your comments

Please share an example of what exactly your server returns. I'm having the same problem (I think) and cannot find a solution. Thanks!

rodolfocondemx commented 6 months ago

Hi @bboldi What I am returning from my custom server is as follows (In the code, the object "receipt" is the original receipt object that I send from my app to my custom server (the receipt generated by the purchase plugin when the user buys the subscription)

{ ok: true,

data: { id: my_subscription_id,

 latest_receipt: true,

 transaction: receipt.transaction,

 // Verified purchases array
 collection: [
     receipt
 ]

} }

I also could verify that the following also works:

{ ok: true,

data: { id: my_subscription_id,

 latest_receipt: true,

 transaction: receipt.transaction,

 // Verified purchases array
 collection: [
     {
        id: my_subscription_id
     }
 ]

} }

Since all other fields on the verified purchase objects are optional

bboldi commented 6 months ago

Thank You!