Hi, I shared here my code after I tested it a lot.
Here it is:
https://gist.github.com/gileli121/3b6396677e9f5345aa43b59badb7ecb3
This is the final answer
https://gist.github.com/gileli121/3b6396677e9f5345aa43b59badb7ecb3
This browser is no longer supported.
Upgrade to Microsoft Edge to take advantage of the latest features, security updates, and technical support.
Hello,
I am the owner of some UWP app that is currently distributed as paid with 30 days trial.
After the 30 days trial, the user has to purchase the app to keep using it.
Now, I plan to change it in a way that the app will be distributed as FREE but with limited functionality.
So that I plan to do change in Microsoft Store Partner Center the submission settings in the following way:
The issue
In my case, I already have customers that purchased the app.
If I will do this change (changing it to freemium that is free with limited functionality), I can't see how I can verify if the old customers (before the change) was purchased the app or not
I did some testings for the API StoreContext.GetAppLicenseAsync()
and I found the following:
Once the app becomes free, the property license.IsActive
is true
in any case. No matter if it was false
before.
In case the customer has not purchased the app, this property is false
. But after the change, the property is true
also for him.
For customer that purchased the app before the change, the property is the same as before - true
(also after the change)
So the issue is that I can't tell if this property is true
because of this change (app become free) or it is true because the customer purchased the app.
As a result, if the app will become free, I have no idea how to check if the customer already paid for the product or not.
I tried to check for the PurchaseDate
property that returned from StoreContext.GetAppLicenseAsync()
and I was surprised to see that even for the user that never purchased it, once the app become free (with pro addon), there was PurchaseDate
property also.
So I am lost and have no idea how to proceed from here...
Please help.
Thank you
Hi, I shared here my code after I tested it a lot.
Here it is:
https://gist.github.com/gileli121/3b6396677e9f5345aa43b59badb7ecb3
This is the final answer
https://gist.github.com/gileli121/3b6396677e9f5345aa43b59badb7ecb3
Hi, I wanted to update you that after a lot of reverse engineering and investigation, finally found the answer.
For anyone else that want to know how to handle it (verify old purchases before the app become freemium as described above), here is how it can be done -
What we need is to get the data from the following code:
c#
var product = await storeContext.GetStoreProductForCurrentAppAsync();
var storeProduct = product.Product;
Once we have storeProduct
instance that is not null, (it can be.. no idea how to reproduce. But check for null please!)
What we need to get from it is property ExtendedJsonData
.
So we need to access storeProduct.ExtendedJsonData
This is just a JSON string that contains the info that can help to verify the purchase after the app become free.
What you should look at is the following -
If you find skuType: Full
, status: Active
, this is the record of the purchase and it is returned by the API in some ugly way (in JSON string...)
You can even verify the orderId
value with a list of orders that you can export from
https://partner.microsoft.com/dashboard/payouts/reports/transactionhistory
If the orderId
value is in the list that you exported, it must be purchase..
But no need to do this extra step. I think that verifies that we have skuType: Full
, status: Active
is fine
I tested a scenario that I made a purchase before the app become free and after that, I changed it to free. Then I checked if I still can see these records that I wrote above. And I still saw these records.
I also tested a negative scenario that the user got the product with 30 days trial (The only way to install the app is to first activate 30 days trial. The other way is if you the developer...) and during the 30 days trial, the app becomes free. In this scenario, I also checked the content of this JSON, and I found that in this case, you will not see CollectionData
node that has skuType: Full
.
You may see only skuType: Trial
and even orderId
that is not null (Why I have order?? WTF). But I think that the big thing is that you don't see skuType: Full
anywhere.
Please let me know if my method is fine.
Thank you!
Hello again,
I just tested scenario 3 that after the app is free with the paid addon, the user just opens Microsoft Store and chooses to install the product via the Store app while it is FREE.
In this case, you WILL have CollectionData node with skuType: Full
, status: Active
so again you can't tell if scenario 3 is scenario 1 or not.
But fortunately, I found a workaround for this.
In addition to properties skuType
and status
, I also get in the same node the properties acquiredDate
, productId
and productKind
So what I think is that if in date X
I changed the app from PAID to FREE with a paid addon,
then the logic will have to ignore it if the acquiredDate
value is after date X
.
So that if the logic found productId
that is the main store product, and productKind
= Application
and also skuType
= Full
, BUT - the acquiredDate
of it is AFTER the app become FREE (with paid addon), then I will consider this as fake order because can't purchase it with money anymore...
Only if also the acquiredDate
value is before date X
(and while all conditions are valid), then this is a valid purchase.
Please fix me if I am wrong again.
Thank you
Update:
Here is my implementation:
static async Task<Result<StoreProduct>> FindDurableAddonAsync(StoreContext storeContext, string addonId)
{
var addonsRequest = await storeContext.GetAssociatedStoreProductsAsync(new[] { "Durable" });
if (addonsRequest.ExtendedError != null)
{
log.Error(
$"Failure in FindDurableAddonAsync. Failed to get list of addons. Error: {addonsRequest.ExtendedError.Message}");
return new Result<StoreProduct>(addonsRequest.ExtendedError, null);
}
if (addonsRequest.Products == null)
{
log.Error("Failure in FindDurableAddonAsync. No products returned");
return new Result<StoreProduct>(log.LastLog, null);
}
var addon = addonsRequest.Products.Values.FirstOrDefault(a => a.StoreId == addonId);
if (addon == null)
{
log.Error("Failure in FindDurableAddonAsync. Failed to find the proVersionAddon.");
return new Result<StoreProduct>(log.LastLog, null);
}
return new Result<StoreProduct>(addon);
}
static async Task<Result<bool>> GetIsAppLegacyPurchasedAsync(StoreContext storeContext)
{
var maxLegacyPurchaseDate = DateTime.ParseExact(
"2021-09-18", // Change this value as needed. From this date, the app become free and the full functionality moved to addon
"yyyy-MM-dd",
null)
.ToUniversalTime();
var storeProductRequest = await storeContext.GetStoreProductForCurrentAppAsync();
if (storeProductRequest.ExtendedError != null)
{
log.Error(
$"Failure in GetIsAppLegacyPurchasedAsync() method. Error: {storeProductRequest.ExtendedError.Message}");
return new Result<bool>(storeProductRequest.ExtendedError, false);
}
var storeProduct = storeProductRequest.Product;
if (storeProduct == null)
{
log.Error("Failure in GetIsAppLegacyPurchasedAsync(). storeProduct returned null");
return new Result<bool>(log.LastLog, false);
}
foreach (var storeProductSku in storeProduct.Skus)
{
SkuExtendedJsonData skuData = null;
try
{
skuData = SkuExtendedJsonData.GetFromJson(storeProductSku.ExtendedJsonData);
}
catch (Exception e)
{
log.Error(
$"Failure in GetIsAppLegacyPurchasedAsync(). Failed to parse skuData. Error: {e.Message}");
continue;
}
if (skuData.Sku == null)
continue;
if (skuData.Sku.CollectionData == null)
continue;
var cd = skuData.Sku.CollectionData;
var isLegacyPurchase =
cd.productId == STORE_PRODUCT_ID &&
cd.skuType == "Full" &&
cd.acquiredDate <= maxLegacyPurchaseDate;
if (isLegacyPurchase)
return new Result<bool>(true);
}
return new Result<bool>(false);
}
static async Task<Result<bool>> GetIsAppNonLegacyPurchasedAsync(StoreContext storeContext)
{
var proVersionAddonResult = await FindDurableAddonAsync(storeContext, STORE_PRO_ADDON_ID);
if (proVersionAddonResult.IsFailed)
{
log.Error($"Failure in GetIsAppNonLegacyPurchasedAsync. Error: {proVersionAddonResult.ErrorMessage}");
return new Result<bool>(log.LastLog, false);
}
return new Result<bool>(proVersionAddonResult.Value.IsInUserCollection);
}
static async Task<Result<bool>> GetIsAppPurchasedAsync(StoreContext storeContext)
{
var isLegacyPurchased = await GetIsAppLegacyPurchasedAsync(storeContext);
if (isLegacyPurchased.Value)
return new Result<bool>(true);
var isProAddonPurchased = await GetIsAppNonLegacyPurchasedAsync(storeContext); // TODO
if (isProAddonPurchased.Value)
return new Result<bool>(true);
return new Result<bool>(false);
}
I hope that it will pass my tests
Here is my implementation:
static async Task<Result<StoreProduct>> FindDurableAddonAsync(StoreContext storeContext, string addonId)
{
var addonsRequest = await storeContext.GetAssociatedStoreProductsAsync(new[] { "Durable" });
if (addonsRequest.ExtendedError != null)
{
log.Error(
$"Failure in FindDurableAddonAsync. Failed to get list of addons. Error: {addonsRequest.ExtendedError.Message}");
return new Result<StoreProduct>(addonsRequest.ExtendedError, null);
}
if (addonsRequest.Products == null)
{
log.Error("Failure in FindDurableAddonAsync. No products returned");
return new Result<StoreProduct>(log.LastLog, null);
}
var addon = addonsRequest.Products.Values.FirstOrDefault(a => a.StoreId == addonId);
if (addon == null)
{
log.Error("Failure in FindDurableAddonAsync. Failed to find the proVersionAddon.");
return new Result<StoreProduct>(log.LastLog, null);
}
return new Result<StoreProduct>(addon);
}
static async Task<Result<bool>> GetIsAppLegacyPurchasedAsync(StoreContext storeContext)
{
var maxLegacyPurchaseDate = DateTime.ParseExact(
"2021-09-18", // Change this value as needed. From this date, the app become free and the full functionality moved to addon
"yyyy-MM-dd",
null)
.ToUniversalTime();
var storeProductRequest = await storeContext.GetStoreProductForCurrentAppAsync();
if (storeProductRequest.ExtendedError != null)
{
log.Error(
$"Failure in GetIsAppLegacyPurchasedAsync() method. Error: {storeProductRequest.ExtendedError.Message}");
return new Result<bool>(storeProductRequest.ExtendedError, false);
}
var storeProduct = storeProductRequest.Product;
if (storeProduct == null)
{
log.Error("Failure in GetIsAppLegacyPurchasedAsync(). storeProduct returned null");
return new Result<bool>(log.LastLog, false);
}
foreach (var storeProductSku in storeProduct.Skus)
{
SkuExtendedJsonData skuData = null;
try
{
skuData = SkuExtendedJsonData.GetFromJson(storeProductSku.ExtendedJsonData);
}
catch (Exception e)
{
log.Error(
$"Failure in GetIsAppLegacyPurchasedAsync(). Failed to parse skuData. Error: {e.Message}");
continue;
}
if (skuData.Sku == null)
continue;
if (skuData.Sku.CollectionData == null)
continue;
var cd = skuData.Sku.CollectionData;
var isLegacyPurchase =
cd.productId == STORE_PRODUCT_ID &&
cd.skuType == "Full" &&
cd.acquiredDate <= maxLegacyPurchaseDate;
if (isLegacyPurchase)
return new Result<bool>(true);
}
return new Result<bool>(false);
}
static async Task<Result<bool>> GetIsAppNonLegacyPurchasedAsync(StoreContext storeContext)
{
var proVersionAddonResult = await FindDurableAddonAsync(storeContext, STORE_PRO_ADDON_ID);
if (proVersionAddonResult.IsFailed)
{
log.Error($"Failure in GetIsAppNonLegacyPurchasedAsync. Error: {proVersionAddonResult.ErrorMessage}");
return new Result<bool>(log.LastLog, false);
}
return new Result<bool>(proVersionAddonResult.Value.IsInUserCollection);
}
static async Task<Result<bool>> GetIsAppPurchasedAsync(StoreContext storeContext)
{
var isLegacyPurchased = await GetIsAppLegacyPurchasedAsync(storeContext);
if (isLegacyPurchased.Value)
return new Result<bool>(true);
var isProAddonPurchased = await GetIsAppNonLegacyPurchasedAsync(storeContext); // TODO
if (isProAddonPurchased.Value)
return new Result<bool>(true);
return new Result<bool>(false);
}
I hope that it will pass my tests