Created
September 9, 2025 10:57
-
-
Save antiero/281227a256bb2ac8387500ac51676cd1 to your computer and use it in GitHub Desktop.
Basic Unity IAP v5 example for Apple In-App Purchase
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // IAPManager.cs | |
| // Unity IAP v5 Manager with UnityEvents, product metadata access, | |
| // and safe handling of pending/confirmed purchases. | |
| // Tested on iOS Sandbox where "pending" often occurs. | |
| using System; | |
| using System.Collections.Generic; | |
| using System.Threading.Tasks; | |
| using UnityEngine; | |
| using UnityEngine.Events; | |
| using UnityEngine.Purchasing; | |
| [Serializable] | |
| public class ProductsEvent : UnityEvent<List<Product>> { } | |
| [Serializable] | |
| public class PurchaseSuccessEvent : UnityEvent<string> { } // productId | |
| [Serializable] | |
| public class PurchaseFailedEvent : UnityEvent<string, string> { } // productId, reason | |
| public class IAPManager : MonoBehaviour | |
| { | |
| // Change these ids to be your IAP productIds in the App Store Connect In-App Purchase | |
| public const string PRODUCT_ID_SINGLE_PLEDGE = "com.mycompany.singlePledge"; | |
| public const string PRODUCT_ID_DOUBLE_PLEDGE = "com.mycompany.singlePledge"; | |
| StoreController storeController; | |
| bool initialized = false; | |
| bool _hasSinglePledge = false; | |
| bool _hasDoublePledge = false; | |
| readonly Dictionary<string, Product> fetchedProducts = new(); | |
| public bool HasSinglePledge => _hasSinglePledge; | |
| public bool HasDoublePledge => _hasDoublePledge; | |
| // === UnityEvents === | |
| [Header("Events")] | |
| public ProductsEvent OnProductsReady; | |
| public PurchaseSuccessEvent OnPurchaseSucceeded; | |
| public PurchaseFailedEvent OnPurchaseFailedEvent; | |
| async void Start() | |
| { | |
| await InitializeIAP(); | |
| } | |
| async Task InitializeIAP() | |
| { | |
| storeController = UnityIAPServices.StoreController(); | |
| // Hook events | |
| storeController.OnProductsFetched += OnProductsFetched; | |
| storeController.OnProductsFetchFailed += OnProductsFetchFailed; | |
| storeController.OnPurchasesFetched += OnPurchasesFetched; | |
| storeController.OnPurchasesFetchFailed += OnPurchasesFetchFailed; | |
| storeController.OnPurchaseConfirmed += OnPurchaseConfirmed; | |
| storeController.OnPurchaseFailed += OnPurchaseFailed; | |
| storeController.OnPurchasePending += OnPurchasePending; | |
| try | |
| { | |
| await storeController.Connect(); | |
| initialized = true; | |
| Debug.Log("IAPv5: Connected"); | |
| var initialProducts = new List<ProductDefinition> | |
| { | |
| new ProductDefinition(PRODUCT_ID_SINGLE_PLEDGE, ProductType.NonConsumable), | |
| new ProductDefinition(PRODUCT_ID_DOUBLE_PLEDGE, ProductType.NonConsumable) | |
| }; | |
| storeController.FetchProducts(initialProducts); | |
| } | |
| catch (Exception ex) | |
| { | |
| Debug.LogError($"IAPv5: Connect failed: {ex}"); | |
| } | |
| } | |
| // === Public API === | |
| public void BuySinglePledge() | |
| { | |
| if (!initialized) { Debug.LogWarning("IAPv5: Not ready"); return; } | |
| storeController.PurchaseProduct(PRODUCT_ID_SINGLE_PLEDGE); | |
| } | |
| public void BuyDoublePledge() | |
| { | |
| if (!initialized) { Debug.LogWarning("IAPv5: Not ready"); return; } | |
| storeController.PurchaseProduct(PRODUCT_ID_DOUBLE_PLEDGE); | |
| } | |
| public void RestorePurchases(Action<bool, string> callback = null) | |
| { | |
| if (!initialized) { callback?.Invoke(false, "not initialized"); return; } | |
| storeController.RestoreTransactions((ok, msg) => | |
| { | |
| Debug.Log($"IAPv5: Restore result {ok} - {msg}"); | |
| callback?.Invoke(ok, msg); | |
| }); | |
| } | |
| // Access product metadata | |
| public string GetLocalizedPrice(string productId) | |
| => fetchedProducts.ContainsKey(productId) ? fetchedProducts[productId].metadata.localizedPriceString : ""; | |
| public string GetCurrencyCode(string productId) | |
| => fetchedProducts.ContainsKey(productId) ? fetchedProducts[productId].metadata.isoCurrencyCode : ""; | |
| public string GetTitle(string productId) | |
| => fetchedProducts.ContainsKey(productId) ? fetchedProducts[productId].metadata.localizedTitle : ""; | |
| public string GetDescription(string productId) | |
| => fetchedProducts.ContainsKey(productId) ? fetchedProducts[productId].metadata.localizedDescription : ""; | |
| // === Event handlers === | |
| void OnProductsFetched(List<Product> products) | |
| { | |
| Debug.Log("IAPv5: Products fetched: " + products.Count); | |
| foreach (var p in products) | |
| { | |
| fetchedProducts[p.definition.id] = p; | |
| Debug.Log($"Fetched: {p.definition.id} | {p.metadata.localizedTitle} | {p.metadata.localizedPriceString}"); | |
| } | |
| OnProductsReady?.Invoke(products); | |
| storeController.FetchPurchases(); | |
| } | |
| void OnProductsFetchFailed(ProductFetchFailed failure) | |
| { | |
| Debug.LogError($"IAPv5: ProductsFetchFailed: {failure.Reason} {failure.Message}"); | |
| } | |
| void OnPurchasesFetched(Orders orders) | |
| { | |
| _hasSinglePledge = CheckOrdersForProduct(orders, PRODUCT_ID_SINGLE_PLEDGE); | |
| _hasDoublePledge = CheckOrdersForProduct(orders, PRODUCT_ID_DOUBLE_PLEDGE); | |
| Debug.Log($"IAPv5: Purchases fetched. Single: {_hasSinglePledge}, Double: {_hasDoublePledge}"); | |
| } | |
| void OnPurchasesFetchFailed(PurchasesFetchFailureDescription failure) | |
| { | |
| Debug.LogError($"IAPv5: PurchasesFetchFailed: {failure.FailureReason} - {failure.Message}"); | |
| } | |
| void OnPurchasePending(PendingOrder pending) | |
| { | |
| Debug.Log($"IAPv5: Purchase pending for {pending.Info.ProductId}"); | |
| // Fire success event immediately so UI/audio/VFX can trigger | |
| OnPurchaseSucceeded?.Invoke(pending.Info.ProductId); | |
| // Confirm with StoreKit so the transaction finalizes | |
| storeController.ConfirmPurchase(pending); | |
| Debug.Log("IAPv5: Pending purchase confirmed with StoreKit"); | |
| } | |
| void OnPurchaseConfirmed(Order order) | |
| { | |
| Debug.Log("IAPv5: Purchase confirmed by StoreKit"); | |
| foreach (var p in order.Info.PurchasedProductInfo) | |
| OnPurchaseSucceeded?.Invoke(p.productId); | |
| } | |
| void OnPurchaseFailed(FailedOrder failed) | |
| { | |
| Debug.LogWarning($"IAPv5: Purchase failed. Reason: {failed.FailureReason}"); | |
| OnPurchaseFailedEvent?.Invoke(failed.Info.ProductId, failed.FailureReason.ToString()); | |
| } | |
| // === Utility === | |
| bool CheckOrdersForProduct(Orders orders, string productId) | |
| { | |
| if (orders == null) return false; | |
| foreach (var co in orders.ConfirmedOrders) | |
| if (OrderContainsProductId(co, productId)) return true; | |
| foreach (var po in orders.PendingOrders) | |
| if (OrderContainsProductId(po, productId)) return true; | |
| foreach (var d in orders.DeferredOrders) | |
| if (OrderContainsProductId(d, productId)) return true; | |
| return false; | |
| } | |
| bool OrderContainsProductId(Order order, string productId) | |
| { | |
| if (order?.Info?.PurchasedProductInfo == null) return false; | |
| foreach (var p in order.Info.PurchasedProductInfo) | |
| if (p.productId == productId) return true; | |
| return false; | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
PendingOrder doesn't include
pending.Info.ProductId.https://docs.unity3d.com/Packages/[email protected]//api/UnityEngine.Purchasing.IOrderInfo.html
I think the ID is inside the
PurchasedProductInfolist, but when I try to retrieve it, the list itself is empty, meaning I have no way to access the product ID. I'm on IAP 5.0.1. Is this for a different version?