From 90ad2ae0a9d8b99aeac55bc9f6d08dc7133c5612 Mon Sep 17 00:00:00 2001 From: Piotr Siuszko Date: Mon, 6 Jul 2026 15:43:08 +0200 Subject: [PATCH 1/3] Unity IAP v5 support --- .../Purchasing/Steam/SteamPurchasingModule.cs | 6 +- .../Modules/Purchasing/Steam/SteamStore.cs | 253 +++++++++++++- .../Unity.Beamable.Purchasing.asmdef | 9 +- .../Purchasing/UnityBeamablePurchaser.cs | 311 +++++++++++++++++- 4 files changed, 566 insertions(+), 13 deletions(-) diff --git a/client/Packages/com.beamable/Runtime/Modules/Purchasing/Steam/SteamPurchasingModule.cs b/client/Packages/com.beamable/Runtime/Modules/Purchasing/Steam/SteamPurchasingModule.cs index 2734950899..4ef6ce68cd 100644 --- a/client/Packages/com.beamable/Runtime/Modules/Purchasing/Steam/SteamPurchasingModule.cs +++ b/client/Packages/com.beamable/Runtime/Modules/Purchasing/Steam/SteamPurchasingModule.cs @@ -1,4 +1,7 @@ -using Beamable.Common.Dependencies; +// Unity IAP v5 removed AbstractPurchasingModule / IStoreConfiguration; the v5 Steam store is +// registered through UnityIAPServices in UnityBeamablePurchaser instead of this module. +#if !UNITY_PURCHASING_5_OR_NEWER +using Beamable.Common.Dependencies; using UnityEngine.Purchasing.Extension; namespace Beamable.Purchasing.Steam @@ -17,4 +20,5 @@ public override void Configure() } } } +#endif diff --git a/client/Packages/com.beamable/Runtime/Modules/Purchasing/Steam/SteamStore.cs b/client/Packages/com.beamable/Runtime/Modules/Purchasing/Steam/SteamStore.cs index ef069283aa..920f6cddee 100644 --- a/client/Packages/com.beamable/Runtime/Modules/Purchasing/Steam/SteamStore.cs +++ b/client/Packages/com.beamable/Runtime/Modules/Purchasing/Steam/SteamStore.cs @@ -1,4 +1,7 @@ -using Beamable.Common.Dependencies; +// Unity IAP v5 removed the v4 IStore / IStoreCallback surface this store is built on. +// The v5 Steam store lives in SteamStoreV5.cs, registered via UnityIAPServices. +#if !UNITY_PURCHASING_5_OR_NEWER +using Beamable.Common.Dependencies; using Beamable.Common.Steam; using System.Collections.Generic; using System.Collections.ObjectModel; @@ -144,4 +147,252 @@ public InProgressPurchase(ProductDefinition product, string transactionId) } } } +#endif + +#if UNITY_PURCHASING_5_OR_NEWER && !BEAMABLE_STEAM_IMPLEMENTATION_DISABLED +using Beamable.Common.Dependencies; +using Beamable.Common.Steam; +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.Purchasing; +using UnityEngine.Purchasing.Extension; + +namespace Beamable.Purchasing.Steam +{ + /// + /// Unity IAP v5 custom store for Steam. Bridges Beamable's onto the v5 + /// abstraction. Registered with the SDK via + /// UnityIAPServices.AddNewCustomStore and selected by name through new StoreController(Name). + /// + public class SteamStoreV5 : Store + { + public const string Name = "SteamStore"; + + private IDependencyProvider _provider; + private ISteamService _steam; + private List _steamProducts; + + // Steam purchases are authorized out-of-band via the transaction callback. Beamable is single-in-flight, + // so we track the cart for the current purchase and resolve it when the authorization arrives. + private ICart _currentCart; + + // The Beamable txid of the in-flight purchase. The Beamable Steam backend initiates the Steam + // microtransaction using the txid as the order id, so authorization callbacks whose transaction id + // does not match are foreign/stale and must be ignored (v4 keyed its in-progress purchases the same way). + private string _expectedTransactionId; + + // The public Store base does not track connection state (that lives on the internal InternalStore), + // so the custom store maintains it for IStoreWrapper.GetStoreConnectionState. + public ConnectionState ConnectionState { get; private set; } = ConnectionState.Disconnected; + + public SteamStoreV5(IDependencyProvider provider) + { + _provider = provider; + } + + /// + /// Refresh the dependency provider. Unity IAP v5 caches store services per store name for the + /// domain lifetime, so this store instance outlives any single BeamContext initialization; each + /// Initialize must hand it the current provider so resolves the live + /// . + /// + public void SetProvider(IDependencyProvider provider) + { + _provider = provider; + } + + /// + /// Record the Beamable txid for the purchase about to start, so only the matching Steam + /// authorization resolves it. Cleared when the purchase resolves; overwritten by the next purchase. + /// + public void SetExpectedTransaction(string transactionId) + { + _expectedTransactionId = transactionId; + } + + public override void Connect() + { + ConnectionState = ConnectionState.Connecting; + var steam = _provider.GetService(); + if (steam == null) + { + ConnectionState = ConnectionState.Unavailable; + Debug.LogError("Steam service unavailable. Provide ServiceManager an ISteamService instance."); + ConnectCallback?.OnStoreConnectionFailed( + new StoreConnectionFailureDescription("Steam service unavailable. Provide ServiceManager an ISteamService instance.")); + return; + } + + // Connect() can re-enter on the same domain-lifetime store instance after a BeamContext + // re-initializes with a new provider. Subscribe once per resolved service instance; there is + // no unsubscribe on ISteamService, but stray callbacks from a stale service are filtered out + // by the expected-transaction check in OnTransactionAuthorized. + if (!ReferenceEquals(steam, _steam)) + { + steam.RegisterTransactionCallback(OnTransactionAuthorized); + } + _steam = steam; + ConnectionState = ConnectionState.Connected; + ConnectCallback?.OnStoreConnectionSucceeded(); + } + + public override void FetchProducts(IReadOnlyCollection products) + { + if (_steam == null) + { + ProductsCallback?.OnProductsFetchFailed( + new ProductFetchFailureDescription(ProductFetchFailureReason.ProviderUnavailable, "Steam service unavailable.")); + return; + } + + _steam.RegisterAuthTicket() + .FlatMap(_ => _steam.GetProducts()) + .Then(rsp => OnRetrieved(products, rsp.products)) + .Error(ex => + { + Debug.LogError("Failed to retrieve steam products."); + Debug.LogError(ex); + ProductsCallback?.OnProductsFetchFailed( + new ProductFetchFailureDescription(ProductFetchFailureReason.Unknown, "Failed to retrieve steam products.")); + }); + } + + private void OnRetrieved(IReadOnlyCollection productDefinitions, List steamProducts) + { + _steamProducts = steamProducts; + + var productDescriptions = new List(); + foreach (var definition in productDefinitions) + { + var steamProduct = steamProducts.Find(r => r.sku == definition.id); + if (steamProduct == null) continue; + + var price = System.Convert.ToDecimal(steamProduct.localizedPrice); + var metadata = new ProductMetadata( + steamProduct.localizedPriceString, + steamProduct.description, + steamProduct.description, + steamProduct.isoCurrencyCode, + price); + + productDescriptions.Add(new ProductDescription(definition.storeSpecificId, metadata)); + } + + ProductsCallback?.OnProductsFetched(productDescriptions); + } + + public override void FetchPurchases() + { + // Steam consumables are not restored between sessions in this integration; report an empty set. + PurchaseFetchCallback?.OnAllPurchasesRetrieved(new List()); + } + + public override void Purchase(ICart cart) + { + // The Steam purchase is authorized out-of-band; OnTransactionAuthorized resolves it. + // Do NOT fail the old cart here: by the time Purchase runs, the purchaser's txid already + // refers to the new transaction, and a late FailedOrder would fail the wrong txid. + if (_currentCart != null) + { + Debug.LogWarning("A previous Steam purchase was still pending and is being abandoned."); + } + _currentCart = cart; + } + + private void OnTransactionAuthorized(SteamTransaction transaction) + { + if (_currentCart == null) + { + return; + } + + // Ignore foreign/stale authorizations without consuming the in-flight cart — the equivalent + // of v4's in-progress lookup keyed by the Beamable txid. + if (_expectedTransactionId != null && transaction.transactionId != _expectedTransactionId) + { + return; + } + + var cart = _currentCart; + _currentCart = null; + _expectedTransactionId = null; + + if (transaction.authorized) + { + PurchaseCallback?.OnPurchaseSucceeded(new PendingOrder(cart, new SteamOrderInfo(transaction.transactionId))); + } + else + { + PurchaseCallback?.OnPurchaseFailed( + new FailedOrder(cart, PurchaseFailureReason.UserCancelled, "Steam purchase cancelled.")); + } + } + + public override void FinishTransaction(PendingOrder pendingOrder) + { + // Steam has no platform-side consume/acknowledgement step, but v5's ConfirmOrderUseCase waits + // for this ack after calling FinishTransaction — without it ConfirmPurchase never completes and + // OnPurchaseConfirmed never fires. + ConfirmCallback?.OnConfirmOrderSucceeded(pendingOrder.Info.TransactionID); + } + + public override void CheckEntitlement(ProductDefinition product) + { + // Beamable does not use entitlement checks, but the request must be answered: v5's + // CheckEntitlementUseCase keeps unanswered requests in its ongoing list forever and rejects + // all later checks for the same product as duplicates. + EntitlementCallback?.OnCheckEntitlement(product, EntitlementStatus.Unknown, + "Entitlement checks are not supported by the Steam store."); + } + } + + /// + /// Minimal implementation for the Steam custom store. The concrete Unity + /// OrderInfo type is internal to the IAP package, so custom stores supply their own. + /// + internal class SteamOrderInfo : IOrderInfo + { + public SteamOrderInfo(string transactionId) + { + TransactionID = transactionId; + // Unity's built-in stores wrap platform receipts into the unified receipt JSON + // ({Store, TransactionID, Payload}) via the internal UnifiedReceiptFormatter. Custom stores + // must do it themselves: UnityBeamablePurchaser parses Order.Info.Receipt with + // JsonUtility.FromJson, which throws on non-JSON input. As in v4, + // the Steam transaction id doubles as the receipt payload for the Beamable backend. + Receipt = JsonUtility.ToJson(new UnityPurchaseReceipt + { + Store = SteamStoreV5.Name, + TransactionID = transactionId, + Payload = transactionId + }); + PurchasedProductInfo = new List(); + } + + public IAppleOrderInfo Apple => null; + public IGoogleOrderInfo Google => null; + public List PurchasedProductInfo { get; set; } + public string Receipt { get; } + public string TransactionID { get; } + } + + /// + /// Wraps for registration with UnityIAPServices.AddNewCustomStore. + /// + public class SteamStoreWrapperV5 : IStoreWrapper + { + private readonly SteamStoreV5 _store; + + public SteamStoreWrapperV5(SteamStoreV5 store) + { + _store = store; + instance = store; + } + + public Store instance { get; } + public string name => SteamStoreV5.Name; + public ConnectionState GetStoreConnectionState() => _store.ConnectionState; + } +} +#endif diff --git a/client/Packages/com.beamable/Runtime/Modules/Purchasing/Unity.Beamable.Purchasing.asmdef b/client/Packages/com.beamable/Runtime/Modules/Purchasing/Unity.Beamable.Purchasing.asmdef index 505462460c..4ce75a81a3 100644 --- a/client/Packages/com.beamable/Runtime/Modules/Purchasing/Unity.Beamable.Purchasing.asmdef +++ b/client/Packages/com.beamable/Runtime/Modules/Purchasing/Unity.Beamable.Purchasing.asmdef @@ -8,7 +8,9 @@ "Beamable.Service", "Unity.Beamable.Runtime.Common", "UnityEngine.Purchasing", - "UnityEngine.Purchasing.Stores" + "UnityEngine.Purchasing.Stores", + "Unity.Purchasing", + "Unity.Purchasing.Stores" ], "includePlatforms": [], "excludePlatforms": [], @@ -26,6 +28,11 @@ "name": "com.unity.purchasing", "expression": "4.6", "define": "UNITY_PURCHASING_4_6_OR_NEWER" + }, + { + "name": "com.unity.purchasing", + "expression": "5.0", + "define": "UNITY_PURCHASING_5_OR_NEWER" } ], "noEngineReferences": false diff --git a/client/Packages/com.beamable/Runtime/Modules/Purchasing/UnityBeamablePurchaser.cs b/client/Packages/com.beamable/Runtime/Modules/Purchasing/UnityBeamablePurchaser.cs index 74815bb26c..69beaf97b8 100644 --- a/client/Packages/com.beamable/Runtime/Modules/Purchasing/UnityBeamablePurchaser.cs +++ b/client/Packages/com.beamable/Runtime/Modules/Purchasing/UnityBeamablePurchaser.cs @@ -17,22 +17,31 @@ namespace Beamable.Purchasing /// /// Implementation of Unity IAP for Beamable purchasing. /// - public class UnityBeamablePurchaser : IStoreListener - , IBeamablePurchaser + public class UnityBeamablePurchaser : IBeamablePurchaser + +#if !UNITY_PURCHASING_5_OR_NEWER // v5 removed the listener interfaces in favor of StoreController events. + , IStoreListener #if UNITY_PURCHASING_4_6_OR_NEWER // if this is a newer IAP, then include the detailed store listener. , IDetailedStoreListener +#endif #endif { public PurchasingInitializationStatus InitializationStatus { get; private set; } = PurchasingInitializationStatus.NotInitialized; +#if UNITY_PURCHASING_5_OR_NEWER + private StoreController _storeController; + // The store product definitions, built during Initialize and fetched once the store connects. + private System.Collections.Generic.List _productDefinitions; +#else private IStoreController _storeController; #pragma warning disable CS0649 private IAppleExtensions _appleExtensions; private IGooglePlayStoreExtensions _googleExtensions; #pragma warning restore CS0649 +#endif private Promise _initPromise = new Promise(); private long _txid; @@ -78,6 +87,66 @@ public Promise Initialize(IDependencyProvider provider) } +#if UNITY_PURCHASING_5_OR_NEWER +#if USE_STEAMWORKS && (!UNITY_EDITOR || BEAMABLE_STEAM_IN_EDITOR) && !!BEAMABLE_STEAM_IMPLEMENTATION_DISABLED + // Register the Steam custom store with the SDK, then target it by name. + // BEAMABLE_STEAM_IN_EDITOR is an opt-in testing hook that forces the Steam store path + // in playmode so a mock ISteamService can drive the full purchase flow. + RegisterSteamStore(); + _storeController = new StoreController(Steam.SteamStoreV5.Name); +#else + _storeController = new StoreController(); +#endif + + // Subscribe to the StoreController events before connecting so no callback is missed. + _storeController.OnStoreConnected += HandleStoreConnected; + _storeController.OnStoreDisconnected += HandleStoreDisconnected; + _storeController.OnProductsFetched += HandleProductsFetched; + _storeController.OnProductsFetchFailed += HandleProductsFetchFailed; + _storeController.OnPurchasePending += HandlePurchasePending; + _storeController.OnPurchaseConfirmed += HandlePurchaseConfirmed; + _storeController.OnPurchaseFailed += HandlePurchaseFailed; + _storeController.OnPurchasesFetched += HandlePurchasesFetched; + + var productDefinitions = new System.Collections.Generic.List(); + foreach (var sku in rsp.skus.definitions) + { + if (sku == null) continue; + + var storeSpecificId = GetStoreSpecificId(sku); + if (string.IsNullOrEmpty(storeSpecificId)) storeSpecificId = sku.name; + productDefinitions.Add(new ProductDefinition(sku.name, storeSpecificId, ProductType.Consumable)); + } + _productDefinitions = productDefinitions; + + // Kick off the split async v5 lifecycle: Connect -> (HandleStoreConnected) FetchProducts + // -> (HandleProductsFetched) FetchPurchases. The promise resolves from the event handlers. + _storeController.Connect(); + return _initPromise; + + // Pick the store-specific product id for the current runtime platform from the Beamable SKU. + // v5's ProductDefinition carries a single store id, unlike v4's multi-store IDs. + string GetStoreSpecificId(SKU sku) + { +#if USE_STEAMWORKS && (!UNITY_EDITOR || BEAMABLE_STEAM_IN_EDITOR) + // Note: unlike v4 (which omitted SKUs lacking a steam id), an unmapped SKU falls back to + // sku.name at the call site — the net effect is the same, because SteamStoreV5.OnRetrieved + // matches Steam products by the Beamable SKU name and simply yields no ProductDescription. + return sku.productIds?.steam; +#else + switch (Application.platform) + { + case RuntimePlatform.IPhonePlayer: + case RuntimePlatform.OSXPlayer: + return sku.productIds?.itunes; + case RuntimePlatform.Android: + return sku.productIds?.googleplay; + default: + return sku.productIds?.itunes ?? sku.productIds?.googleplay; + } +#endif + } +#else #if USE_STEAMWORKS && !UNITY_EDITOR var builder = ConfigurationBuilder.Instance(new Steam.SteamPurchasingModule(_serviceProvider)); foreach (var sku in rsp.skus.definitions) @@ -94,7 +163,7 @@ public Promise Initialize(IDependencyProvider provider) foreach (var sku in rsp.skus.definitions) { if (sku == null) continue; - + var ids = ExtractValidIdsFromSku( new SkuIdPair {skuProductId = sku.productIds?.itunes, storeId = AppleAppStore.Name,}, new SkuIdPair {skuProductId = sku.productIds?.googleplay, storeId = GooglePlay.Name,} @@ -108,7 +177,7 @@ public Promise Initialize(IDependencyProvider provider) // response either in OnInitialized or OnInitializeFailed. UnityPurchasing.Initialize(this, builder); return _initPromise; - + // Create an IDs instance from a list of Beamable SKU product id and IAP store ids. // This method will only add the beamable SKU product ids that are not null or empty strings. IDs ExtractValidIdsFromSku(params SkuIdPair[] skuIdPairs) @@ -120,9 +189,10 @@ IDs ExtractValidIdsFromSku(params SkuIdPair[] skuIdPairs) if (string.IsNullOrEmpty(pair.skuProductId)) continue; ids.Add(pair.skuProductId, pair.storeId); } - + return ids; } +#endif }); } @@ -149,16 +219,48 @@ private CoroutineService GetCoroutineService() return _serviceProvider.GetService(); } +#if UNITY_PURCHASING_5_OR_NEWER && USE_STEAMWORKS && (!UNITY_EDITOR || BEAMABLE_STEAM_IN_EDITOR) && !BEAMABLE_STEAM_IMPLEMENTATION_DISABLED + // Unity IAP v5 caches store services per store name for the domain lifetime + // (StoreServiceContainer throws on duplicate registration), so the Steam store instance is a + // domain-lifetime singleton whose dependency provider must be refreshed on every Initialize. + private static Steam.SteamStoreV5 _registeredSteamStore; + private Steam.SteamStoreV5 _steamStore; + + // Register the Beamable Steam custom store with Unity IAP v5 exactly once per domain, and keep a + // handle so StartPurchase can pass the expected Beamable txid to the store. + private void RegisterSteamStore() + { + if (_registeredSteamStore == null) + { + _registeredSteamStore = new Steam.SteamStoreV5(_serviceProvider); + UnityIAPServices.AddNewCustomStore(new Steam.SteamStoreWrapperV5(_registeredSteamStore)); + } + else + { + _registeredSteamStore.SetProvider(_serviceProvider); + } + _steamStore = _registeredSteamStore; + } +#endif + #region "IBeamablePurchaser" public string GetLocalizedPrice(string skuSymbol) { +#if UNITY_PURCHASING_5_OR_NEWER + var product = _storeController?.GetProductById(skuSymbol); +#else var product = _storeController?.products.WithID(skuSymbol); +#endif return product?.metadata.localizedPriceString ?? "???"; } public bool TryGetLocalizedPrice(string skuSymbol, out string localizedPrice) { +#if UNITY_PURCHASING_5_OR_NEWER + var product = _storeController?.GetProductById(skuSymbol); +#else var product = _storeController?.products.WithID(skuSymbol); +#endif localizedPrice = product?.metadata.localizedPriceString ?? string.Empty; return !string.IsNullOrEmpty(localizedPrice); } @@ -189,7 +291,19 @@ public Promise StartPurchase(string listingSymbol, string GetPaymentService().BeginPurchase(listingSymbol).Then(rsp => { _txid = rsp.txid; +#if UNITY_PURCHASING_5_OR_NEWER + // v5 has no developer-payload argument; the in-flight purchase is tracked via _txid + // (single-in-flight assumption, unchanged from v4). +#if USE_STEAMWORKS && (!UNITY_EDITOR || BEAMABLE_STEAM_IN_EDITOR) + // The Beamable Steam backend initiates the Steam microtransaction with the txid as order + // id; hand it to the store so only the matching authorization resolves this purchase. + _steamStore?.SetExpectedTransaction(_txid.ToString()); +#endif + var product = _storeController.GetProductById(skuSymbol); + _storeController.PurchaseProduct(product); +#else _storeController.InitiatePurchase(skuSymbol, _txid.ToString()); +#endif }).Error(err => { Debug.LogError($"There was an exception making the begin purchase request: {err}"); @@ -211,6 +325,12 @@ public Promise StartPurchase(string listingSymbol, string /// public void RestorePurchases() { +#if USE_STEAMWORKS && (!UNITY_EDITOR || BEAMABLE_STEAM_IN_EDITOR) + // Steam has no restore flow. This guard also matters on macOS, where + // Application.platform == OSXPlayer is true for Steam builds and the Apple restore path + // would otherwise run against the Steam store. + InAppPurchaseLogger.Log("RestorePurchases skipped: not supported on the Steam store."); +#else if (Application.platform == RuntimePlatform.IPhonePlayer || Application.platform == RuntimePlatform.OSXPlayer) { @@ -218,16 +338,18 @@ public void RestorePurchases() // Begin the asynchronous process of restoring purchases. Expect a confirmation response in // the Action below, and ProcessPurchase if there are previously purchased products to restore. -#if UNITY_PURCHASING_4_6_OR_NEWER +#if UNITY_PURCHASING_5_OR_NEWER + _storeController.RestoreTransactions((result, error) => +#elif UNITY_PURCHASING_4_6_OR_NEWER _appleExtensions.RestoreTransactions((result, error) => #else _appleExtensions.RestoreTransactions(result => #endif { - // The first phase of restoration. If no more responses are received on ProcessPurchase then - // no purchases are available to be restored. + // The first phase of restoration. If no more responses are received on the pending-purchase + // callback then no purchases are available to be restored. InAppPurchaseLogger.Log("RestorePurchases continuing: " + result + ". If no further messages, no purchases available to restore."); -#if UNITY_PURCHASING_4_6_OR_NEWER +#if UNITY_PURCHASING_5_OR_NEWER || UNITY_PURCHASING_4_6_OR_NEWER if (!string.IsNullOrWhiteSpace(error)) { InAppPurchaseLogger.Log($"Error: {error}"); @@ -240,9 +362,134 @@ public void RestorePurchases() // If we are not running on an Apple device, no work is necessary to restore purchases. InAppPurchaseLogger.Log("RestorePurchases FAIL. Not supported on this platform. Current = " + Application.platform); } +#endif } #region "IStoreListener" +#if UNITY_PURCHASING_5_OR_NEWER + /// + /// React to a successful store connection by fetching the configured products. + /// + private void HandleStoreConnected() + { + InAppPurchaseLogger.Log("Store connected. Fetching products."); + _storeController.FetchProducts(_productDefinitions); + } + + /// + /// React to a store disconnection. Only fails initialization if it happens before init completes. + /// + private void HandleStoreDisconnected(StoreConnectionFailureDescription description) + { + Debug.LogError($"Billing store disconnected! {description?.message}"); + if (InitializationStatus == PurchasingInitializationStatus.InProgress) + { + InitializationStatus = PurchasingInitializationStatus.ErrorPurchasingUnavailable; + _initPromise.CompleteError(new BeamableIAPInitializationException(InitializationStatus, description?.message)); + } + } + + /// + /// React to products being fetched. This is the v5 analog of v4's OnInitialized: the store is + /// now ready. Fetch outstanding purchases (which drives restore/fulfillment) and resolve init. + /// + private void HandleProductsFetched(System.Collections.Generic.List products) + { + InitializationStatus = PurchasingInitializationStatus.Success; + InAppPurchaseLogger.Log("Successfully initialized IAP."); + _storeController.FetchPurchases(); + RestorePurchases(); + if (!_initPromise.IsCompleted) + { + _initPromise.CompleteSuccess(PromiseBase.Unit); + } + } + + /// + /// React to a product fetch failure by failing initialization. + /// + private void HandleProductsFetchFailed(ProductFetchFailed failure) + { + InitializationStatus = PurchasingInitializationStatus.ErrorNoProductsAvailable; + InAppPurchaseLogger.Log($"No products available for purchase! {failure?.FailureReason}"); + if (!_initPromise.IsCompleted) + { + _initPromise.CompleteError(new BeamableIAPInitializationException(InitializationStatus, failure?.FailureReason)); + } + } + + /// + /// Process a pending purchase (new or restored) by fulfilling it through the payments service. + /// This is the v5 analog of v4's ProcessPurchase. Outstanding purchases fetched at startup are + /// rerouted here automatically by the StoreController for built-in stores only (Apple/Google/etc.); + /// custom stores such as Steam are excluded, which is moot because SteamStoreV5.FetchPurchases + /// reports an empty set (v4 parity — Steam consumables are not restored). + /// + private void HandlePurchasePending(PendingOrder order) + { + var product = GetOrderProduct(order); + + string rawReceipt; + var receiptString = order.Info?.Receipt; + if (!string.IsNullOrEmpty(receiptString)) + { + var receipt = JsonUtility.FromJson(receiptString); + rawReceipt = receipt != null && !string.IsNullOrEmpty(receipt.Payload) ? receipt.Payload : receiptString; + InAppPurchaseLogger.Log($"UnityIAP Payload: {rawReceipt}"); + InAppPurchaseLogger.Log($"UnityIAP Raw Receipt: {receiptString}"); + } + else + { + rawReceipt = receiptString; + } + + var transaction = new CompletedTransaction( + _txid, + rawReceipt, + product != null ? product.metadata.localizedPrice.ToString() : string.Empty, + product != null ? product.metadata.isoCurrencyCode : string.Empty + ); + FulfillTransaction(transaction, order); + } + + /// + /// React to a confirmed purchase. Success is already reported from FulfillTransaction. + /// + private void HandlePurchaseConfirmed(Order order) + { + InAppPurchaseLogger.Log("Purchase confirmed."); + } + + /// + /// React to a failed purchase from Unity IAP v5. + /// + private void HandlePurchaseFailed(FailedOrder failedOrder) + { + var product = GetOrderProduct(failedOrder); + var productId = product?.definition?.storeSpecificId; + OnPurchaseFailedInternal(product, failedOrder.FailureReason, new OptionalString(failedOrder.Details), new OptionalString(productId)); + } + + /// + /// React to fetched purchases. For built-in stores, outstanding pending orders are automatically + /// rerouted through OnPurchasePending by the StoreController, so this is informational only. + /// (Custom stores are not rerouted; the Steam store reports an empty set here.) + /// + private void HandlePurchasesFetched(Orders orders) + { + InAppPurchaseLogger.Log($"Purchases fetched. Pending: {orders?.PendingOrders?.Count}, Confirmed: {orders?.ConfirmedOrders?.Count}."); + } + + /// + /// Resolve the Product associated with an order via its cart. + /// + private static Product GetOrderProduct(Order order) + { + var items = order?.CartOrdered?.Items(); + if (items == null || items.Count == 0) return null; + return items[0].Product; + } +#else /// /// React to successful Unity IAP initialization. /// @@ -349,6 +596,7 @@ public void OnPurchaseFailed(Product product, PurchaseFailureDescription failure OnPurchaseFailedInternal(product, failureDescription.reason, new OptionalString(failureDescription.message), new OptionalString(failureDescription.productId)); } #endif +#endif // UNITY_PURCHASING_5_OR_NEWER private void OnPurchaseFailedInternal(Product product, PurchaseFailureReason failureReason, OptionalString message, OptionalString productId) { @@ -380,12 +628,21 @@ private void OnPurchaseFailedInternal(Product product, PurchaseFailureReason fai /// payments service and informing Unity IAP of completion. /// /// Completed IAP transaction +#if UNITY_PURCHASING_5_OR_NEWER + /// The pending order being fulfilled + private void FulfillTransaction(CompletedTransaction transaction, PendingOrder purchasedOrder) +#else /// The product being purchased private void FulfillTransaction(CompletedTransaction transaction, Product purchasedProduct) +#endif { GetPaymentService().CompletePurchase(transaction).Then(_ => { +#if UNITY_PURCHASING_5_OR_NEWER + _storeController.ConfirmPurchase(purchasedOrder); +#else _storeController.ConfirmPendingPurchase(purchasedProduct); +#endif _success?.Invoke(transaction); ClearCallbacks(); }).Error(ex => @@ -409,11 +666,19 @@ private void FulfillTransaction(CompletedTransaction transaction, Product purcha var retryable = err.Code >= 500 || err.Code == 429 || err.Code == 0; // Server error or rate limiting or network error if (retryable) { +#if UNITY_PURCHASING_5_OR_NEWER + GetCoroutineService().StartCoroutine(RetryTransaction(transaction, purchasedOrder)); +#else GetCoroutineService().StartCoroutine(RetryTransaction(transaction, purchasedProduct)); +#endif } else { +#if UNITY_PURCHASING_5_OR_NEWER + _storeController.ConfirmPurchase(purchasedOrder); +#else _storeController.ConfirmPendingPurchase(purchasedProduct); +#endif _fail?.Invoke(err); ClearCallbacks(); } @@ -424,8 +689,13 @@ private void FulfillTransaction(CompletedTransaction transaction, Product purcha /// If fulfillment failed, retry fulfillment with a backoff, as a coroutine. /// /// The failed transaction +#if UNITY_PURCHASING_5_OR_NEWER + /// The pending order that should have been fulfilled + private IEnumerator RetryTransaction(CompletedTransaction transaction, PendingOrder purchasedOrder) +#else /// The product that should have been fulfilled private IEnumerator RetryTransaction(CompletedTransaction transaction, Product purchasedProduct) +#endif { // This block should only be hit when the error returned from the request is retryable. This lives down here // because C# doesn't allow you to yield return from inside a try..catch block. @@ -442,7 +712,11 @@ private IEnumerator RetryTransaction(CompletedTransaction transaction, Product p yield return new WaitForSeconds(waitTime); +#if UNITY_PURCHASING_5_OR_NEWER + FulfillTransaction(transaction, purchasedOrder); +#else FulfillTransaction(transaction, purchasedProduct); +#endif } } @@ -470,6 +744,7 @@ public static void RegisterServices(IDependencyBuilder builder) } } +#if !UNITY_PURCHASING_5_OR_NEWER // IDs is a v4-only type; v5 builds ProductDefinitions directly. public class UnityBeamablePurchaserUtil { /// @@ -487,13 +762,28 @@ public static IDs CreateIdsFromSku(params SkuIdPair[] skuIdPairs) if (string.IsNullOrEmpty(pair.skuProductId)) continue; ids.Add(pair.skuProductId, pair.storeId); } - + return ids; } } +#endif public class BeamableIAPInitializationException : Exception { +#if UNITY_PURCHASING_5_OR_NEWER + /// + /// The Beamable initialization status at the time of failure. Unity IAP v5 surfaces + /// initialization problems as a status + message rather than the legacy + /// InitializationFailureReason enum, so v5 throws through this constructor. + /// + public PurchasingInitializationStatus Status { get; } + + public BeamableIAPInitializationException(PurchasingInitializationStatus status, string message) : base( + $"Beamable IAP failed due to: {status} ({message})") + { + Status = status; + } +#else public InitializationFailureReason Reason { get; } public BeamableIAPInitializationException(InitializationFailureReason reason) : base( @@ -501,6 +791,7 @@ public BeamableIAPInitializationException(InitializationFailureReason reason) : { Reason = reason; } +#endif } From ff5c217a0be8e230f7f7f8ecb53beefcbb8f4d76 Mon Sep 17 00:00:00 2001 From: Piotr Siuszko Date: Mon, 6 Jul 2026 16:41:28 +0200 Subject: [PATCH 2/3] Allow disabling UnityBeamablePurchaser with BEAMABLE_PURCHASING_IMPLEMENTATION_DISABLED --- .../Runtime/Modules/Purchasing/UnityBeamablePurchaser.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/Packages/com.beamable/Runtime/Modules/Purchasing/UnityBeamablePurchaser.cs b/client/Packages/com.beamable/Runtime/Modules/Purchasing/UnityBeamablePurchaser.cs index 69beaf97b8..3f97932a7c 100644 --- a/client/Packages/com.beamable/Runtime/Modules/Purchasing/UnityBeamablePurchaser.cs +++ b/client/Packages/com.beamable/Runtime/Modules/Purchasing/UnityBeamablePurchaser.cs @@ -9,11 +9,14 @@ using System; using System.Collections; using UnityEngine; +#if !BEAMABLE_PURCHASING_IMPLEMENTATION_DISABLED using UnityEngine.Purchasing; using UnityEngine.Purchasing.Extension; +#endif namespace Beamable.Purchasing { +#if !BEAMABLE_PURCHASING_IMPLEMENTATION_DISABLED /// /// Implementation of Unity IAP for Beamable purchasing. /// @@ -719,7 +722,7 @@ private IEnumerator RetryTransaction(CompletedTransaction transaction, Product p #endif } } - +#endif public struct SkuIdPair { /// @@ -740,7 +743,9 @@ public static class UnityBeamablePurchaserRegister public static void RegisterServices(IDependencyBuilder builder) { builder.RemoveIfExists(); +#if !BEAMABLE_PURCHASING_IMPLEMENTATION_DISABLED builder.AddSingleton(); +#endif } } From bfff4ef04caebcec69712716ba97d15130f61e0e Mon Sep 17 00:00:00 2001 From: Piotr Siuszko Date: Thu, 9 Jul 2026 16:52:32 +0200 Subject: [PATCH 3/3] Simplify the code --- .../Modules/Purchasing/UnityBeamablePurchaser.cs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/client/Packages/com.beamable/Runtime/Modules/Purchasing/UnityBeamablePurchaser.cs b/client/Packages/com.beamable/Runtime/Modules/Purchasing/UnityBeamablePurchaser.cs index 3f97932a7c..7992866827 100644 --- a/client/Packages/com.beamable/Runtime/Modules/Purchasing/UnityBeamablePurchaser.cs +++ b/client/Packages/com.beamable/Runtime/Modules/Purchasing/UnityBeamablePurchaser.cs @@ -89,7 +89,7 @@ public Promise Initialize(IDependencyProvider provider) return _initPromise; } - + #if UNITY_PURCHASING_5_OR_NEWER #if USE_STEAMWORKS && (!UNITY_EDITOR || BEAMABLE_STEAM_IN_EDITOR) && !!BEAMABLE_STEAM_IMPLEMENTATION_DISABLED // Register the Steam custom store with the SDK, then target it by name. @@ -432,7 +432,7 @@ private void HandlePurchasePending(PendingOrder order) { var product = GetOrderProduct(order); - string rawReceipt; + string rawReceipt = string.Empty; var receiptString = order.Info?.Receipt; if (!string.IsNullOrEmpty(receiptString)) { @@ -441,10 +441,6 @@ private void HandlePurchasePending(PendingOrder order) InAppPurchaseLogger.Log($"UnityIAP Payload: {rawReceipt}"); InAppPurchaseLogger.Log($"UnityIAP Raw Receipt: {receiptString}"); } - else - { - rawReceipt = receiptString; - } var transaction = new CompletedTransaction( _txid, @@ -729,7 +725,7 @@ public struct SkuIdPair /// The product id from Beamable's SKU content. /// public string skuProductId; - + /// /// The IAP store id ///