Hello, I have a game published on both iOS and Android platform. I am having issues since the beginning with IAP purchases. Purchases are sometimes being awarded twice to the users on Android. iOS users, seem to bypass receipt validation and being able to use cheating apps to fake IAP purchases. I was using Codeless IAP with customized code, but since I read some comments on forum I completely removed it from the project and now Im just using regular IAP plugin (at least I believe I removed it)
The problem in Android seems to happen when user restarts the app, it seems like the purchase got stuck at pending state and is being awarded again upon restarting.
On iOS it seems like receipt validation is not working as intended, since some users complete a purchase correctly (boolean validPurchase from code below returns true) but it is a fake one, using IAP simulator apps.
I believe the problem in Android might be there are two listeners for purchases in the Project but I already removed every script except the needed ones. On iOS I have no idea what might be happening. Can you check the code and see if something is missing or something is not correct?
I removed unrelated code:
public class GemShop : MonoBehaviour, IStoreListener
{
private static IStoreController m_StoreController; // The Unity Purchasing system.
private static IExtensionProvider m_StoreExtensionProvider; // The store-specific Purchasing subsystems.
void Start()
{
// If we haven't set up the Unity Purchasing reference
if (m_StoreController == null)
{
// Begin to configure our connection to Purchasing
InitializePurchasing();
}
}
public void InitializePurchasing()
{
// If we have already connected to Purchasing ...
if (IsInitialized())
{
// ... we are done here.
return;
}
// Create a builder, first passing in a suite of Unity provided stores.
var builder = ConfigurationBuilder.Instance(StandardPurchasingModule.Instance());
// Add a product to sell / restore by way of its identifier, associating the general identifier
// with its store-specific identifiers.
builder.AddProduct(gems100, ProductType.Consumable);
builder.AddProduct(gems500, ProductType.Consumable);
builder.AddProduct(gems1200, ProductType.Consumable);
builder.AddProduct(gems2500, ProductType.Consumable);
builder.AddProduct(gems5500, ProductType.Consumable);
builder.AddProduct(gems15000, ProductType.Consumable);
UnityPurchasing.Initialize(this, builder);
}
public bool IsInitialized()
{
// Only say we are initialized if both the Purchasing references are set.
return m_StoreController != null && m_StoreExtensionProvider != null;
}
//BUTTON FOR BUYING 100 GEMS
public void Buy100gems()
{
if (!buying)
{
BuyProductID(gems100);
}
}
void BuyProductID(string productId)
{
buying = true;
// If Purchasing has been initialized ...
if (IsInitialized())
{
// ... look up the Product reference with the general product identifier and the Purchasing
// system's products collection.
Product product = m_StoreController.products.WithID(productId);
// If the look up found a product for this device's store and that product is ready to be sold ...
if (product != null && product.availableToPurchase)
{
Debug.Log(string.Format("Purchasing product asychronously: '{0}'", product.definition.id));
// ... buy the product. Expect a response either through ProcessPurchase or OnPurchaseFailed
// asynchronously.
m_StoreController.InitiatePurchase(product);
}
// Otherwise ...
else
{
// ... report the product look-up failure situation
Debug.Log("BuyProductID: FAIL. Not purchasing product, either is not found or is not available for purchase");
buying = false;
}
}
// Otherwise ...
else
{
// ... report the fact Purchasing has not succeeded initializing yet. Consider waiting longer or
// retrying initiailization.
Debug.Log("BuyProductID FAIL. Not initialized.");
buying = false;
}
}
// Restore purchases previously made by this customer. Some platforms automatically restore purchases, like Google.
// Apple currently requires explicit purchase restoration for IAP, conditionally displaying a password prompt.
public void RestorePurchases()
{
// If Purchasing has not yet been set up ...
if (!IsInitialized())
{
// ... report the situation and stop restoring. Consider either waiting longer, or retrying initialization.
Debug.Log("RestorePurchases FAIL. Not initialized.");
return;
}
// If we are running on an Apple device ...
if (Application.platform == RuntimePlatform.IPhonePlayer ||
Application.platform == RuntimePlatform.OSXPlayer)
{
// ... begin restoring purchases
Debug.Log("RestorePurchases started ...");
// Fetch the Apple store-specific subsystem.
var apple = m_StoreExtensionProvider.GetExtension<IAppleExtensions>();
// Begin the asynchronous process of restoring purchases. Expect a confirmation response in
// the Action<bool> below, and ProcessPurchase if there are previously purchased products to restore.
apple.RestoreTransactions((result) =>
{
// The first phase of restoration. If no more responses are received on ProcessPurchase then
// no purchases are available to be restored.
Debug.Log("RestorePurchases continuing: " + result + ". If no further messages, no purchases available to restore.");
});
}
// Otherwise ...
else
{
// We are not running on an Apple device. No work is necessary to restore purchases.
Debug.Log("RestorePurchases FAIL. Not supported on this platform. Current = " + Application.platform);
buying = false;
}
}
//
// --- IStoreListener
//
public void OnInitialized(IStoreController controller, IExtensionProvider extensions)
{
// Purchasing has succeeded initializing. Collect our Purchasing references.
Debug.Log("OnInitialized: PASS");
// Overall Purchasing system, configured with products for this application.
m_StoreController = controller;
// Store specific subsystem, for accessing device-specific store features.
m_StoreExtensionProvider = extensions;
price100.text = m_StoreController.products.WithID(gems100).metadata.localizedPriceString;
price500.text = m_StoreController.products.WithID(gems500).metadata.localizedPriceString;
price1200.text = m_StoreController.products.WithID(gems1200).metadata.localizedPriceString;
price2500.text = m_StoreController.products.WithID(gems2500).metadata.localizedPriceString;
price5500.text = m_StoreController.products.WithID(gems5500).metadata.localizedPriceString;
price15000.text = m_StoreController.products.WithID(gems15000).metadata.localizedPriceString;
}
public void OnInitializeFailed(InitializationFailureReason error)
{
// Purchasing set-up has not succeeded. Check error for reason. Consider sharing this reason with the user.
Debug.Log("OnInitializeFailed InitializationFailureReason:" + error);
buying = false;
}
public PurchaseProcessingResult ProcessPurchase(PurchaseEventArgs args)
{
bool validPurchase = true; // Presume valid for platforms with no R.V.
#if UNITY_ANDROID || UNITY_IOS || UNITY_STANDALONE_OSX
var validator = new CrossPlatformValidator(GooglePlayTangle.Data(),
AppleTangle.Data(), Application.identifier);
try
{
var result = validator.Validate(args.purchasedProduct.receipt);
}
catch (IAPSecurityException)
{
Debug.Log("Invalid receipt, not unlocking content");
validPurchase = false;
buying = false;
}
#endif
if (validPurchase)
{
// Unlock the appropriate content here.
if (String.Equals(args.purchasedProduct.definition.id, gems100, StringComparison.Ordinal))
{
Debug.Log(string.Format("ProcessPurchase: PASS. Product: '{0}'", args.purchasedProduct.definition.id));
// HERE USER PURCHASES 100 GEMS CORRECTLY
}
.......
else
{
Debug.Log(string.Format("ProcessPurchase: FAIL. Unrecognized product: '{0}'", args.purchasedProduct.definition.id));
}
}
if (validPurchase)
{
buying = false;
return PurchaseProcessingResult.Complete;
}
else
{
buying = false;
return PurchaseProcessingResult.Pending;
}
// Return a flag indicating whether this product has completely been received, or if the application needs
// to be reminded of this purchase at next app launch. Use PurchaseProcessingResult.Pending when still
// saving purchased products to the cloud, and when that save is delayed.
}
public string GetproductPrice(string id)
{
if (m_StoreController != null && m_StoreController.products != null)
{
return m_StoreController.products.WithID(id).metadata.localizedPriceString;
}
else
{
return "";
}
}
public void OnPurchaseFailed(Product product, PurchaseFailureReason failureReason)
{
// A product purchase attempt did not succeed. Check failureReason for more detail. Consider sharing
// this reason with the user to guide their troubleshooting actions.
scripter.GetComponent<Alerts>().ShowAlert(Lang.purchasefail[Lang.lang]);
buying = false;
Debug.Log(string.Format("OnPurchaseFailed: FAIL. Product: '{0}', PurchaseFailureReason: {1}", product.definition.storeSpecificId, failureReason));
}
}
Thanks for your reply, I cannot provide logs, since I am not able to replicate the issue, it happen on specific scenarios which circustamces are unknown to me, I am just guessing what might be happening.
i’d like some feedback regarding the code, since this is the only script handling purchases and its mostly copied from the links you provided, except for a minor tweaks. Thanks
Are you using an IAPButton? You don’t have any listeners in your code (and you shouldn’t have for scripted IAP) Please describe the issue in detail. Are your users complaining about purchases? Why do you think people are cheating?
Hello, first of all pardon my grammar, as english is not my main language.
I have several buttons, (just regular UI buttons), for example one of them for buying 100 gems, just calls this method (it’s all in the code I pasted above)
BuyProductID(gems100);
On Android it works fine, user correctly purchases the 100 gems, but in some cases, after the user restart the app, or the scene gets reloaded, they get another 100 gems automatically, but they only got charged once (the first time, which is correct). This was reported to me several times, and on some occasions I could see it myself in the database, comparing it to Google Console purchases:
for example > user buy 15000 gems > he has 30000 in database, but only purchased 15000 once.
I believe this might be because purchase is still pending for some reason, or is being processed twice, this is just me guessing.
On iOs the issue is worse. Whenever an user complete a purchase (purchase returns Completed, and receipt is validated) I can see a handler on his account on the database, which indicate me that the user made a valid purchase, but that is not always the case. For example I see the user purchased 15000 gems, but often the real ammount is over 100k, which is impossible, also admited by some users that they have cheated.
There are some apps, that I dont want to name here, which simulate an IAP purchase, and they can bypass the receipt validation, So I guess I might be doing something wrong.
This is the code I think is messing up iOS validation:
public PurchaseProcessingResult ProcessPurchase(PurchaseEventArgs args)
{
bool validPurchase = true; // Presume valid for platforms with no R.V.
#if UNITY_ANDROID || UNITY_IOS || UNITY_STANDALONE_OSX
var validator = new CrossPlatformValidator(GooglePlayTangle.Data(),
AppleTangle.Data(), Application.identifier);
try
{
var result = validator.Validate(args.purchasedProduct.receipt);
}
catch (IAPSecurityException)
{
Debug.Log("Invalid receipt, not unlocking content");
validPurchase = false;
buying = false;
}
Understood, I just wanted to make sure you’re not using the Codeless IAPButton prefab. I’ve never heard of fraud on iOS. On Google yes, all the time unfortunately. One thing to test. Try to purchase a product, then cancel. Then restart the app and see if you see the ProcessPurchase. Please provide the device logs from your own testing, I might be able to spot something even if everything is working as expected. And what do you mean, handler on the database? There are no database calls in your code.
Yes. I removed the unrelated bits from the code, this is everything that happen when for example buy 100 gems, but this dont affect purchase itself, I just made it write a node on the Database:
Yes, thats something I noticed, but it forces me to return either Completed or Pending. Cant I return something else when purchase is cancelled or failed?
I dont notice anything wrong, I just see that there is still a failed purchase appearing upon restarting, but as an user I didnt notice anything, and gems didnt get awarded
I mean, i just did what you recommended and pasted the logs. I am not able to reproduce the issue, since its been reported on some occasions by players. Just need to find issues with the code.
Got it, thanks. So you are referring to the cancelled purchase. Hopefully you’ll find specific steps to reproduce what your users are reporting. When you do, please post here.