Closed rodolfocondemx closed 6 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:
when().approved()
is firedtransaction.verify()
gets calledSuccessPayload
via my custom validator functionwhen().verified()
is fired
finished
herereceipt.nativeTransactions
the corresponding in_app
entry has is_trial_period = true
receipt.finish()
gets calledwhen().finished()
is firedAt 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?
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.
@uKioops are you using a custom receipt validation or iaptic?
@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.
@uKioops if product.owned
works for you on iOS, would you mind sharing your workflow?
@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>
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.
Fantastic, thanks heaps for the large amounts of input. I'll go through all of it asap.
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.
@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)
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.
@j3k0 did you really mean "should be an issue" or was it "should not be an issue" with a custom receipt validation endpoint?
I really mean "should", as in: "that's probably an issue with a custom receipt validation service" (assuming you are not using iaptic).
What are you returning from the "MY_CUSTOM_ANDROID_VALIDATOR_URL"
?
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.
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);
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
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!
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
Thank You!
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