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..7992866827 100644
--- a/client/Packages/com.beamable/Runtime/Modules/Purchasing/UnityBeamablePurchaser.cs
+++ b/client/Packages/com.beamable/Runtime/Modules/Purchasing/UnityBeamablePurchaser.cs
@@ -9,30 +9,42 @@
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.
///
- 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;
@@ -77,7 +89,67 @@ 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.
+ // 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 +166,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 +180,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 +192,10 @@ IDs ExtractValidIdsFromSku(params SkuIdPair[] skuIdPairs)
if (string.IsNullOrEmpty(pair.skuProductId)) continue;
ids.Add(pair.skuProductId, pair.storeId);
}
-
+
return ids;
}
+#endif
});
}
@@ -149,16 +222,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 +294,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 +328,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 +341,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 +365,130 @@ 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 = string.Empty;
+ 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}");
+ }
+
+ 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 +595,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 +627,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 +665,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 +688,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,17 +711,21 @@ 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
}
}
-
+#endif
public struct SkuIdPair
{
///
/// The product id from Beamable's SKU content.
///
public string skuProductId;
-
+
///
/// The IAP store id
///
@@ -466,10 +739,13 @@ public static class UnityBeamablePurchaserRegister
public static void RegisterServices(IDependencyBuilder builder)
{
builder.RemoveIfExists();
+#if !BEAMABLE_PURCHASING_IMPLEMENTATION_DISABLED
builder.AddSingleton();
+#endif
}
}
+#if !UNITY_PURCHASING_5_OR_NEWER // IDs is a v4-only type; v5 builds ProductDefinitions directly.
public class UnityBeamablePurchaserUtil
{
///
@@ -487,13 +763,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 +792,7 @@ public BeamableIAPInitializationException(InitializationFailureReason reason) :
{
Reason = reason;
}
+#endif
}