In case I changed my app from paid to freemium, how I can determine previous purchase?

Gil 1 226 Reputation points
2021-09-17T22:49:24.053+00:00

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:

  • Pricing: 8.99$ --> FREE
  • Adding an addon called "Pro Version" with a price tag of 8.99$. The app will be free, but it will unlock the pro functionality if it will see that the addon is in the user collection

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

Microsoft Partner Center
Microsoft Partner Center
A Microsoft website for partners that provides access to product support, a partner community, and other partner services.
877 questions
0 comments No comments
{count} votes

3 additional answers

Sort by: Most helpful
  1. Gil 1 226 Reputation points
    2021-09-18T08:38:37.473+00:00

    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 -

    133311-image.png

    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!

    1 person found this answer helpful.
    0 comments No comments

  2. Gil 1 226 Reputation points
    2021-09-18T09:08:36.853+00:00

    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

    1 person found this answer helpful.
    0 comments No comments

  3. Gil 1 226 Reputation points
    2021-09-18T22:26:45.417+00:00

    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

    1 person found this answer helpful.