Search code examples
react-nativeexpoin-app-purchase

Async methods inside setPurchaseListener method calls multiple time when do a subscription


I am using expo-in-app-purchases to do my purchases.

when I do a test subscription first time processNewPurchase(purchase) only calls once (correct behaviour). But after 5 minutes (since it is a test subscription) subscription got cancelled and again try to do the subscription processNewPurchase(purchase) calls twice and so on, (next time it is thrice).

InAppPurchases.setPurchaseListener(
      ({ responseCode, results, errorCode }) => {
        // Purchase was successful
        if (responseCode === InAppPurchases.IAPResponseCode.OK) {
          results.forEach(async (purchase) => {
            if (!purchase.acknowledged) {
              await processNewPurchase(purchase)

              // finish the transaction on platform's end
              InAppPurchases.finishTransactionAsync(purchase, true)
            }
          })

          // handle particular error codes
        } else if (
          responseCode === InAppPurchases.IAPResponseCode.USER_CANCELED
        ) {
          console.log('User canceled the transaction')
        } else if (responseCode === InAppPurchases.IAPResponseCode.DEFERRED) {
          console.log(
            'User does not have permissions to buy but requested parental approval (iOS only)'
          )
        } else {
          console.warn(
            `Something went wrong with the purchase. Received errorCode ${errorCode}`
          )
        }

        setProcessing(false)
      }
    )

since setPurchaseListener should be in global state , I use that in App.js as <IAPManagerWrapped>

export default function App() {
  return (
    <ErrorBoundary onError={errorHandler}>
      <Auth>
        <IAPManagerWrapped>
          <Provider store={store}>
            <NavigationContainer ref={navigationRef}>
              <StatusBar barStyle='dark-content' />
              <Main navigationRef={navigationRef} />
            </NavigationContainer>
          </Provider>
        </IAPManagerWrapped>
      </Auth>
    </ErrorBoundary>
  )
}

In my purchase screen (PurchaseScreen.js), I am using useIAP() hook to get the product details.

  const { getProducts } = useIap()

  const [subscription, setSubscription] = useState([])

  useEffect(() => {
    getProducts().then((results) => {
      console.log(results)
      if (results && results.length > 0) {
        const sub = results.find((o) => o.productId === subscribeId)
        if (sub) {
          setSubscription(sub)
        }
      }
    })
    return () => {}
  }, [])

What is the reason for calling processNewPurchase(purchase) inside setPurchaseListener multiple times ?

Thank you.


Solution

  • I came across the same problem with consumable products. After spending a lot of time on debugging, I noticed that the purchase handler is being called only once; But on android it contains more than 1 products including the previous purchases in the list.

    As mentioned in the official docs here

    https://docs.expo.dev/versions/latest/sdk/in-app-purchases/#inapppurchasessetpurchaselistenercallback-result-iapqueryresponse--void

    On Android, it will return both finished and unfinished purchases, hence the array return type. This is because the Google Play Billing API detects purchase updates but doesn't differentiate which item was just purchased, therefore there's no good way to tell but in general it will be whichever purchase has acknowledged set to false, so those are the ones that you have to handle in the response. Consumed items will not be returned however, so if you consume an item that record will be gone and no longer appear in the results array when a new purchase is made.

    Though it states that

    Consumed items will not be returned however, so if you consume an item that record will be gone and no longer appear in the results array when a new purchase is made.

    But I was still getting the previously purchased item in the list even though it was acknowledged on the server side and consumed on the client side. But still when in the same session (App was not restarted and a new purchase was made), it was showing as acknowledged: false in the array list. Therefore our check in the code !purchase.acknowledged was not filtering out the previously purchased item(s).

    results.forEach(async (purchase) => {
      if (!purchase.acknowledged) {
        await processNewPurchase(purchase)
    
        // finish the transaction on platform's end
        InAppPurchases.finishTransactionAsync(purchase, true)
      }
    })
    

    and as we can see from the code, our processNewPurchase function is inside the loop so it is being called multiple times depending on the array length.

    Problem

    So the problem is that the library does not mark the acknowledged/consumed products in the same session correctly and returns them back as non acknowledged/consumed items in the next purchase (don't know if its fault on Google Side or inside the library), and our loop which was supposed to run only once, runs more than once resulting in calling our success code multiple times.

    Solution

    Fortunately, in the array, when it returns the previous purchases, it returns the associated unique purchaseToken with each purchase, and we can use them to know which purchase has already been processed and which has not been processed yet. Here is a sudo code of my implementation to solve this issue.

    1. Send all the purchases to server
    2. Filter the unprocessed purchase from the incoming purchases. [recorded in step 3]
    3. Process the filtered unprocessed purchase and keep the record of the processed purchase to help filtering the unprocessed purchase next time.
    

    Important Note

    Purchase Listener gets called every time if we call getPurchaseHistoryAsync So it is not called only when a new purchase is made. To make sure we grant correct entitlements, a verification of the purchase from Google Servers is necessary.

    https://developer.android.com/google/play/billing/security