The IAP 5.0 API overhaul is not good + Bugs

It perplexes me why the APIs are designed this way:

  1. Why does one need 3 separate service interfaces when it could be just wrapped behind a single Store class? It’s also very not logical to call ConnectAsync on IStoreService and somehow it also influences the other two services IProductService and IPurchaseService, where the design makes it seem that they are independent of each other?
  2. Connecting to the store is async-capable: ConnectAsync. But why does all the other APIs like FetchProducts, PurchaseProduct, ConfirmOrder is not async, and one has to register callbacks to listen for the result?
  3. Why use Add... and Remove... method pairs to register callbacks instead of using event?
  4. The sample code is needlessly convoluted IMO. Even in PaywallManager the subroutines (void methods with no parameters that are only called once) are scattered without logical orders and no comments. It makes the initialization, setup and purchase flow really hard to follow.

Update, adding more:

  1. Some API naming is confusing, e.g. void AddFetchedPurchasesAction(Action<Orders>? updatedAction); Could have been AddFetchedOrdersAction.
  2. BUG: When purchasing an item on an actual iOS device, the ConfirmOrder will throw a JSON Parse Error caused by UnityEngine.Purchasing.CoreAnalyticsAdapter.BuildTransactionParameters, where the receipt string passed into the method is no longer wrapped in the UnifiedReceipt JSON model, but the method still attempt to parse it as such. I have forked the package to patch it locally, see below. Constructing a reproduction project will take me time so I won’t be doing this voluntarily – I expected this to be a minimum test case you guys should have covered by default.
    // Runtime/Stores/Analytics/AnalyticsClient.cs:15
    public void OnPurchaseSucceeded(ConfirmedOrder confirmedOrder)
    {
        foreach (var cartItem in confirmedOrder.CartOrdered.Items())
        {
            if (!cartItem.Product.appleProductIsRestored)
            {
                UnifiedReceipt unifiedReceipt = new()
                {
                    Payload = confirmedOrder.Info.Receipt,
                    TransactionID = confirmedOrder.Info.TransactionID
                };
                
                m_Analytics.SendTransactionEvent(cartItem, JsonUtility.ToJson(unifiedReceipt));
            }
        }
    }
    
5 Likes

Here’s my wrapper class to make it less ugly to use, based on my interpretation of the API.

public class UnityIAP
{
    private readonly IStoreService storeService = UnityIAPServices.DefaultStore();
    private readonly IProductService productService = UnityIAPServices.DefaultProduct();
    private readonly IPurchaseService purchasingService = UnityIAPServices.DefaultPurchase();

    private Task? connectionTask;
    private TaskCompletionSource<bool>? fetchProductsCompletionSource;
    private TaskCompletionSource<PendingOrder>? purchaseCompletionSource;
    private TaskCompletionSource<ConfirmedOrder>? confirmOrderCompletionSource;
    private TaskCompletionSource<Orders>? fetchOrdersCompletionSource;

    public UnityIAP()
    {
        storeService.AddOnStoreDisconnectedAction(OnStoreDisconnected);

        productService.AddProductsUpdatedAction(OnProductsUpdated);
        productService.AddProductsFetchFailedAction(OnProductFetchFailed);

        purchasingService.AddPendingOrderUpdatedAction(OnNewPendingOrder);
        purchasingService.AddConfirmedOrderUpdatedAction(OnOrderConfirmed);
        purchasingService.AddPurchaseFailedAction(OnPurchaseFailed);
        purchasingService.AddFetchedPurchasesAction(OnOrdersFetched);
        purchasingService.AddFetchPurchasesFailedAction(OnOrdersFetchFailed);
    }

    public Task ConnectAsync()
    {
        if(connectionTask is null || connectionTask.WasUnsuccessful())
        {
            connectionTask = storeService.ConnectAsync();
        }

        return connectionTask;
    }

    /// <summary>
    /// Sets real money prices for the given IAP items.
    /// </summary>
    /// <param name="iapItems"> IAP items to set real money prices for. </param>
    /// <exception cref="InvalidOperationException"> Thrown when connection to the store has not been established. </exception>
    /// <exception cref="ProductFetchException"> Thrown when fetching products fails. </exception>
    public async Task SetRealMoneyPricesAsync(Dictionary<MarketplaceID, IAPItemInfo> iapItems)
    {
        EnsureSuccessfulConnection();

        fetchProductsCompletionSource?.TrySetCanceled();
        fetchProductsCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously);

        productService.FetchProductsWithNoRetries(iapItems.Select(IAPItemInfoExtensions.ToProductDefinition).ToList());

        await fetchProductsCompletionSource.Task;

        IReadOnlyCollection<Product> existingProducts = productService.GetProducts();

        foreach(Product product in existingProducts)
        {
            if(iapItems.TryGetValue(new(product.definition.id), out IAPItemInfo? iapItemInfo))
            {
                iapItemInfo.RealMoneyPrice = new(product.metadata.isoCurrencyCode, product.metadata.localizedPrice);
            }
        }
    }

    /// <summary>
    /// Requests the purchase of an IAP item.
    /// </summary>
    /// <param name="marketplaceID">Marketplace ID of the IAP item to purchase</param>
    /// <returns>A pending order that needs to be confirmed once processed by the game server</returns>
    /// <exception cref="InvalidOperationException">Thrown when connection to the store has not been established.</exception>
    /// <exception cref="PurchaseExceptionWithReason">Thrown when purchasing the product fails.</exception>
    public async Task<PendingOrder> PurchaseProductAsync(MarketplaceID marketplaceID)
    {
        EnsureSuccessfulConnection();

        purchaseCompletionSource?.TrySetCanceled();
        purchaseCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously);

        Product product = productService.GetProducts().First(p => p.definition.id == (string)marketplaceID);

        purchasingService.PurchaseProduct(product);

        return await purchaseCompletionSource.Task;
    }

    /// <summary>
    /// Confirms a pending order.
    /// </summary>
    /// <param name="order">A pending order to confirm</param>
    /// <returns>A confirmed order</returns>
    /// <exception cref="InvalidOperationException">Thrown when connection to the store has not been established.</exception>
    /// <exception cref="PurchaseExceptionWithReason">Thrown when confirming the order fails.</exception>
    public async Task<ConfirmedOrder> ConfirmOrderAsync(PendingOrder order)
    {
        EnsureSuccessfulConnection();

        confirmOrderCompletionSource?.TrySetCanceled();
        confirmOrderCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously);

        purchasingService.ConfirmOrder(order);

        return await confirmOrderCompletionSource.Task;
    }

    /// <summary>
    /// Fetches all pending and confirmed orders.
    /// </summary>
    /// <returns>Collection of all pending and confirmed orders</returns>
    /// <exception cref="InvalidOperationException">Thrown when connection to the store has not been established.</exception>
    /// <exception cref="PurchaseFetchException">Thrown when fetching orders fails.</exception>
    public async Task<Orders> FetchOrdersAsync()
    {
        EnsureSuccessfulConnection();

        fetchOrdersCompletionSource?.TrySetCanceled();
        fetchOrdersCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously);

        purchasingService.FetchPurchases();

        return await fetchOrdersCompletionSource.Task;
    }

    private void OnStoreDisconnected(StoreConnectionFailureDescription description)
    {
        connectionTask = null;
    }

    private void OnProductsUpdated(List<Product> products)
    {
        fetchProductsCompletionSource?.TrySetResult(true);
    }

    private void OnProductFetchFailed(ProductFetchFailed productFetchFailed)
    {
        fetchProductsCompletionSource?.TrySetException(new ProductFetchException(productFetchFailed.FailureReason));
    }

    private void OnNewPendingOrder(PendingOrder pendingOrder)
    {
        purchaseCompletionSource?.TrySetResult(pendingOrder);
    }

    private void OnOrderConfirmed(ConfirmedOrder confirmedOrder)
    {
        confirmOrderCompletionSource?.TrySetResult(confirmedOrder);
    }

    private void OnPurchaseFailed(FailedOrder failedOrder)
    {
        PurchaseExceptionWithReason purchaseExceptionWithReason = new(failedOrder.FailureReason, failedOrder.Details);

        purchaseCompletionSource?.TrySetException(purchaseExceptionWithReason);
        confirmOrderCompletionSource?.TrySetException(purchaseExceptionWithReason);
    }

    private void OnOrdersFetched(Orders orders)
    {
        fetchOrdersCompletionSource?.TrySetResult(orders);
    }

    private void OnOrdersFetchFailed(PurchasesFetchFailureDescription fetchOrdersFailed)
    {
        fetchOrdersCompletionSource?.TrySetException(new PurchaseFetchException(fetchOrdersFailed.Message));
    }

    private void EnsureSuccessfulConnection()
    {
        if(connectionTask is null || connectionTask.WasUnsuccessful())
        {
            throw new InvalidOperationException("IAP Service connection was not established, or was lost.");
        }
    }
}

public class PurchaseExceptionWithReason : PurchaseException
{
    public PurchaseFailureReason FailureReason { get; }

    public PurchaseExceptionWithReason(PurchaseFailureReason failureReason, string message) : base(message)
    {
        FailureReason = failureReason;
    }
}

public static class TaskExtensions
{
    public static bool WasUnsuccessful(this Task task) => task is { IsCompleted: true, IsCompletedSuccessfully: false };
}
2 Likes

hey @Neonlyte !
Thank you for the very detailed feedback! The IAP team truly appreciates the time you took to outline these points. Here’s our response to each and our plan for adjustments:

  1. Separate Service Interfaces: You noted that having three interfaces (IStoreService, IProductService, and IPurchaseService) feels complex, as they’re interdependent and might benefit from being wrapped in a single Store class.
    Response: While separating services supports flexibility, we see how a unified Store wrapper could simplify things, especially if a more direct interface aligns with common usage. We’re considering adding this wrapper to streamline the experience while keeping platform-specific capabilities intact.

  2. Asynchronous Design Inconsistency: Only ConnectAsync is asynchronous, while other methods (like FetchProducts and PurchaseProduct) rely on callbacks, which feels inconsistent.
    Response: We’re reviewing the design for making these additional calls fully asynchronous (async/await), which could help simplify managing results and improve consistency across APIs.

  3. Callback Registration with Add/Remove: Using Add() and Remove() methods to register callbacks, rather than events, was noted as a usability hurdle.
    Response: Using events could indeed make subscription and unsubscription more intuitive. We’re considering if switching to events would enhance usability, though we’ll confirm to make sure the approach aligns with common C# event patterns.

  4. Sample Code Complexity: You found the sample code difficult to follow, with scattered subroutines and limited comments, impacting clarity around initialization and purchase flow.
    Response: We’ll be reviewing the sample code to improve organization, order subroutines logically, and add more comments to clarify workflows. Please feel free to provide additional feedback as we make these updates!

  5. Inconsistent API Naming: AddFetchedPurchasesAction could be clearer as AddFetchedOrdersAction, and you found naming consistency a bit confusing.
    Response: We agree that clearer, consistent naming enhances usability. Please let us know if there are additional instances where naming felt unclear, and we’ll ensure our updates maintain a unified terminology across APIs.

  6. JSON Parsing Error on iOS: You mentioned a JSON parse error on iOS devices in CoreAnalyticsAdapter.BuildTransactionParameters due to receipt format inconsistencies.
    Response: This issue was addressed in a recent update (two months ago), and we’ll confirm through testing that this fix applies across both Apple and Google platforms.

These insights are now documented in our backlog, and your feedback is invaluable in guiding these improvements. Thanks again for the thoughtful input - if you have more suggestions, we’re here to listen!

2 Likes

Thank you very much for the considerations. I look forward to future improvements.

Specifically for the bug in item 6, is there a new version available in package manager? I installed 5.0.0-pre1 by name.

1 Like

Hey @Neonlyte , thank you as well for your feedback!
We wanted to let you know that the changes you’ve requested are planned for implementation in our upcoming version 5.0.0-pre.2. If you have any additional questions or suggestions, feel free to reach out!

Hi, I agree with Neonlyte, the example is overly complex.
I also found another bug, the package uses a generic “editor” namespace which breaks several plugin.
After installing the package i get a lot of

"error CS0118: 'Editor' is a namespace but is used like a type".

I guess it’s identical to this:

Thank you for the report, I confirmed that this fix will be part of 5.0.0-pre.4.

For the example being overly complex, would you be able to provide more specific feedback to help us improve it?

The sample should be barebone, with the minimum required for the API to work. I don’t understand the need for separating callbacks and the manager in two files with pointer to each other because devs will divide in essentially 2 categories:
1-Beginner who are starting with iap and they want to see a purchase dialog as soon as possible
2-Advanced users who likely have some wrapper and want to quickly adapt the wrapper to the new API.

Both of them will have to navigate through scattered code just to filter out the real meat.
Just like the example scene, we just want to see quickly:
-This method initialize the plugin
-This method purchase an item
-This method restore purchases
etc.

Leave the rest to each dev. Maybe divide the code in section instead of separating them in multiple files, when I first downloaded the sample and opened the scene I thought IAPPaywallCallbacks, IAPLogger, etc were all part of the package and not some script you wrote for the example.

4 Likes

Thank you for the detailed feedback, this is really helpful!

We currently plan to release 5.0.0-pre.4 with bug fixes as soon as we can, but you can expect improvements to the sample in the next prerelease after this.

When do you think it will be released as stable? More than 3 months?
I have to decide if stick with 5.0 pre or continue with 4.x but working with it is driving me crazy because testing on the emulator sometimes google billing fail to initialize for some reason and the current 4.x don’t return the error and it’s making it harder to debug my code, since it the code is stuck on waiting in that case.

We still have no official date for 5.0.0, but the remaining work should be done within 3 months. Most of it is related to the documentation and sample, so there won’t be significant changes between 5.0.0-pre.4 and the official 5.0.0 release.

5.0.0-pre.4 is also coming out soon and all known issues have been fixed. If new issues are discovered, we will fix them quickly.

1 Like

Hi, It seem I have the same exact issue with the package 5.0. When testing sometimes something get stuck and the billing fail to initialize. I can try as many times as I want but won’t run.
Sending a new build won’t fix it, but deleting the app and uploading the same build does fix it.
It seems something get cached that screws up everything.

2025/01/27 11:34:39.909 3750 3812 Info Unity [ServicesCore]: {
2025/01/27 11:34:39.909 3750 3812 Info Unity   "CommonSettings": {
2025/01/27 11:34:39.909 3750 3812 Info Unity     "installation_id": "72075c19e660464ab809214746a55a20"
2025/01/27 11:34:39.909 3750 3812 Info Unity   },
2025/01/27 11:34:39.909 3750 3812 Info Unity   "ServicesRuntimeSettings": {
2025/01/27 11:34:39.909 3750 3812 Info Unity     "com.unity.services.core.environment-name": "production",
2025/01/27 11:34:39.909 3750 3812 Info Unity     "com.unity.services.core.cloud-environment": "production",
2025/01/27 11:34:39.909 3750 3812 Info Unity     "com.unity.services.core.version": "1.13.0",
2025/01/27 11:34:39.909 3750 3812 Info Unity     "com.unity.services.core.initializer-assembly-qualified-names": "Unity.Services.Core.Registration.CorePackageInitializer, Unity.Services.Core.Registration, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null;Unity.Services.Core.Internal.IInitializablePackageV2, Unity.Services.Core.Internal, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null",
2025/01/27 11:34:39.909 3750 3812 Info Unity     "com.unity.purchasing.version": "5.0.0-pre.3",
2025/01/27 11:34:39.909 3750 3812 Info Unity     "com.unity.purchasing.initializer-assembly-qualified-names": "UnityEngine.Purchasing.Registration.IapCoreInitializeCallback, Unity.Purchasing.Stores, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null",
2025/01/27 11:34:39.909 3750 3812 Info Unity     "com.unity.services.core.all-package-names": "com.unity.services.core;com.unity.purchasing"
2025/01/27 11:34:39.909 3750 3812 Info Unity   }
2025/01/27 11:34:39.925 3750 3812 Warn BillingClient Connection to Billing service is blocked.
2025/01/27 11:34:39.945 3750 3812 Warn BillingClient Connection to Billing service is blocked.
2025/01/27 11:34:39.947 3750 3812 Warn BillingClient Connection to Billing service is blocked.
2025/01/27 11:34:39.949 3750 3812 Warn BillingClient Connection to Billing service is blocked.
2025/01/27 11:34:39.962 3750 3812 Info Unity ===========
2025/01/27 11:34:39.962 3750 3812 Info Unity StoreConnectionException:
2025/01/27 11:34:39.962 3750 3812 Info Unity GooglePlayStore connection failed
2025/01/27 11:34:39.975 3750 3750 Info UnityChoreograp type=1400 audit(0.0:5787): avc: denied { call } for scontext=u:r:untrusted_app:s0:c52,c256,c512,c768 tcontext=u:r:init:s0 tclass=binder permissive=1 app=net.xxxx.xxxxxxx
2025/01/27 11:34:39.998 3750 3750 Info UnityMain type=1400 audit(0.0:5788): avc: denied { transfer } for scontext=u:r:untrusted_app:s0:c52,c256,c512,c768 tcontext=u:r:init:s0 tclass=binder permissive=1 app=net.xxxx.xxxx
2025/01/27 11:34:44.636 3750 3812 Warn BillingClient Connection to Billing service is blocked.
2025/01/27 11:34:44.632 3750 3750 Info UnityMain type=1400 audit(0.0:5795): avc: denied { sendto } for path="/dev/socket/logdw" scontext=u:r:untrusted_app:s0:c52,c256,c512,c768 tcontext=u:r:init:s0 tclass=unix_dgram_socket permissive=1 app=net.xxxxx.xxxxx
2025/01/27 11:34:44.639 3750 3812 Warn BillingClient Connection to Billing service is blocked.
2025/01/27 11:34:44.641 3750 3812 Warn BillingClient Connection to Billing service is blocked.
2025/01/27 11:34:44.643 3750 3812 Warn BillingClient Connection to Billing service is blocked.
2025/01/27 11:34:44.647 3750 3812 Info Unity ===========
2025/01/27 11:34:44.647 3750 3812 Info Unity StoreConnectionException:
2025/01/27 11:34:44.647 3750 3812 Info Unity GooglePlayStore connection failed
2025/01/27 11:34:47.764 3750 3812 Error Unity StoreConnectionException: Product Service couldn't execute its task, store connection state: Disconnected
2025/01/27 11:34:47.764 3750 3812 Error Unity   at UnityEngine.Purchasing.ProductService.CheckStoreConnectionState () [0x00036] in <4cd7f557d2c74543adc1c38cf797bb84>:0 
2025/01/27 11:34:47.764 3750 3812 Error Unity   at UnityEngine.Purchasing.ProductService.GetProducts () [0x00000] in <4cd7f557d2c74543adc1c38cf797bb84>:0 
2025/01/27 11:34:47.764 3750 3812 Error Unity   at PaywallManager.GetFetchedProducts () [0x0000b] in <b5c388caf7a141f8b111419dc454afda>:0 
2025/01/27 11:34:47.764 3750 3812 Error Unity   at PaywallManager.FindProduct (System.String productId) [0x0000d] in <b5c388caf7a141f8b111419dc454afda>:0 
2025/01/27 11:34:47.764 3750 3812 Error Unity   at PaywallManager.InitiatePurchase (System.String productId) [0x00000] in <b5c388caf7a141f8b111419dc454afda>:0 
2025/01/27 11:34:47.764 3750 3812 Error Unity   at UnityEngine.Events.InvokableCall`1[T1].Invoke (T1 args0) [0x00010] in <b2693653fbb74b35ab2e7985947ebcac>:0 
2025/01/27 11:34:47.764 3750 3812 Error Unity   at UnityEngine.Events.CachedInvokableCall`1[T].Invoke (System.Object[] args) [0x00001] in <b2693653fbb74b35ab2e7985947ebcac>:0 
2025/01/27 11:34:47.764 3750 3812 Error Unity   at UnityEngine.Events.UnityEvent.Invoke () [0x00074] in <b2693653fbb74b35ab2e7985947ebcac>:0 
2025/01/27 11:34:47.764 3750 3812 Error Unity   at UnityEngine.UI.Button.Pre

This error indicates that your Billing service is being blocked which is between your device/account and Google.

A few things to try:

  • Make sure the Google Play Store works properly and nothing is blocked there
  • Clear the Google Play Store’s cache (the BillingClient caches things there)
  • Try a different account on the Google Play Store

Hi, but why is it fixed by removing and reinstalling the app? If the issue is on google side it shouldn’t change the outcome.
The only difference between pushing a new build overwriting the current one and removing and sending the build is that the app data gets cleared. Unless google save something in the app data the issue is in the iap package, since I didn’t do any of the above and it was fixed by just removing the app.

EDIT
I just had the same error again and I just unblock it by deleting the app data, so something get cached in the app data that block it.

Another thing, the existing sample doesn’t seem to retry pending orders on app restart like the previous package did.

Also please do not mix the UI code with the core sample code, it will make harder to integrate the sample code in the project. Things like this, the method shouldn’t rely on some variable in the button:

bool ShouldConfirmOrderAutomatically(PendingOrder order)
{
    var containsItemToNotAutoConfirm = false;
    var containsItemToAutoConfirm = false;

    foreach (var cartItem in order.CartOrdered.Items())
    {
        var matchingButton = FindMatchingButtonByProduct(cartItem.Product.definition.id);

        if (matchingButton)
        {
            if (matchingButton.consumePurchase)
            {
                containsItemToAutoConfirm = true;
            }
            else
            {
                containsItemToNotAutoConfirm = true;
            }
        }
    }

    if (containsItemToNotAutoConfirm && containsItemToAutoConfirm)
    {
        Debug.Log("===========");
        Debug.Log("Pending Order contains some products to not confirm. Confirming by default!");
    }

    return containsItemToAutoConfirm;
}
1 Like

When you mention clearing the app data, this is on your application with the IAP package or on the Google Play Store?

We do know that the Google Play Store cache is being used by the BillingClient.

For the sample, we will correct this, thank you!

I mean this, “clear storage” from my app, not play store app, which basically is the same as resetting the app.
This is the only thing that unfreeze the situation. Clearing play store cache does not fix it.

Next week, we will release 5.0.0-pre.4 which also updates the Google Play Billing Library to v7, there’s a chance that this issue will go away with the update.

If that doesn’t work, could you give us more information on your test device and steps to reproduce (if possible) so we can escalate this error with Google?

Strangely it didn’t happen anymore in the last few days. It was occurring every day before.
I found another bug. The fake store window to process purchases, the one with “buy/cancel” doesn’t block raycast, when you click on buy/cancel you are also clicking on whatever is behind it.
It’s a minor issue but before I did realize this was the problem I thought I had some mistake in my code since i didn’t notice the buttons behind, so it may confuse devs not aware of the issue.

1 Like

Hi,
Today I tried the new version and by coincidence I got the play store connection blocked as soon as I tried it so I started investigating what could be the issue and removed the shared preference one by one.
When it was facebook sdk turn, I deleted all 6 of them in block as I thought there was no way it was that, but instead it restarted working when I deleted them.
Do you have any idea what could be the reason that Facebook make the IAP plugin fail?
All I can think of is that I am loading all the plugin at once, and maybe this does overload the device/emulator and make it reach a timeout. Is there a timeout for the play billing connection?
Still I never logged in facebook for several months since that part was working, so even deleting these files the execution should be the same.