react-native-iap icon indicating copy to clipboard operation
react-native-iap copied to clipboard

requestPurchase always uses first base plan regardless of provided offerToken

Open eden170 opened this issue 1 month ago • 3 comments

Hi,

After making an Android requestPurchase request, not only the purchase data received in onSuccess, but also the activeSubscriptions data obtained via getActiveSubscriptions after the purchase, returns the first subscription plan within the same subscription group, rather than the subscription plan corresponding to the offerToken I passed when calling requestPurchase.

It seems that a similar issue existed in expo-iap and was resolved there. I would really appreciate it if you could confirm whether this fix has also been applied to react-native-iap.

(Actually, I also brought up this issue in the expo-iap discussions, but didn’t receive a clear response. I’ve now switched to react-native-iap and am running tests, so I would really appreciate it if you could take a look.)

Just for your information, I checked the order management on google play console, and the plan I requested was successfully logged not like the purchase return on onPurchaseSuccess and the return from the getActiveSubscriptions.

environment "react-native": "0.79.6" "react-native-iap": "14.4.44"

example code

import React, { useCallback, useEffect, useState } from 'react';
import { TouchableOpacity, Text, View, StyleSheet } from 'react-native';
import { useIAP } from 'react-native-iap';
import { ios } from '@constants/os';
import { subscriptionItemSkus } from '@constants/sku';

const TestPurchaseScreen = () => {
  const {
    fetchProducts,
    subscriptions: subscriptionProducts,
    requestPurchase,
    getActiveSubscriptions,
    activeSubscriptions,
    finishTransaction,
  } = useIAP({
    onPurchaseSuccess: async (purchase: any) => {
      console.log('Purchase:', purchase);
    },
  });


  const fetchIAPProducts = async () => {
    try {
      await fetchProducts({ skus: subscriptionItemSkus, type: 'subs' });
    } catch (error) {
      console.error('IAP product get error:', error);
    }
  };

  useEffect(() => {
    fetchIAPProducts();
  }, []);

  useEffect(() => {
    if (!connected) {
      console.log('IAP not connected');
      return;
    }
  }, [connected]);

  useEffect(() => {
    if (connected) {
      const fetchPurchaseData = async () => {
        try {
          await fetchIAPProducts();
          await getActiveSubscriptions();
        } catch (error) {
          console.error('Purchase data fetch failed:', error);
        }
      };
      fetchPurchaseData();
    }
  }, [connected]);

  const handleTestPurchase = async (productId: string) => {
    try {
      if (ios) {
        await requestPurchase({
          request: { ios: { sku: productId }, android: { skus: [productId] } },
          type: 'subs',
        });
      } else {
        const groupId = productId.startsWith('a') ? 'group_a' : 'group_b'; //subscription group id
        const productGroup = subscriptionProducts.find(
          (group: any) => group.id === groupId
        );
        const offerToken = productGroup?.subscriptionOfferDetailsAndroid?.find(
          (offer: any) => !offer.offerId && offer.basePlanId === productId
        )?.offerToken;

        await requestPurchase({
          request: {
            ios: { sku: productId },
            android: {
              skus: [groupId],
              subscriptionOffers: [{ sku: groupId, offerToken }],
            },
          },
          type: 'subs',
        });
      }
    } catch (error) {
      console.error('Purchase failed:', error);
    }
  };

  return (
    <View style={styles.container}>
      <TouchableOpacity
        style={styles.button}
        onPress={() => handleTestPurchase('a_1wk')}>
        <Text style={styles.buttonText}>a 1week</Text>
      </TouchableOpacity>
      <TouchableOpacity
        style={styles.button}
        onPress={() => handleTestPurchase('a_1mo')}>
        <Text style={styles.buttonText}>a 1month</Text>
      </TouchableOpacity>
      <TouchableOpacity
        style={styles.button}
        onPress={() => handleTestPurchase('b_1wk')}>
        <Text style={styles.buttonText}>b 1week</Text>
      </TouchableOpacity>
      <TouchableOpacity
        style={styles.button}
        onPress={() => handleTestPurchase('b_1mo')}>
        <Text style={styles.buttonText}>b 1month</Text>
      </TouchableOpacity>
    </View>
  );
};

export default TestPurchaseScreen;

eden170 avatar Nov 22 '25 19:11 eden170

Looking at HybridRnIap.kt:373-396, the subscriptionOffers are correctly forwarded to OpenIAP:

val subscriptionOffers = androidRequest.subscriptionOffers
    ?.mapNotNull { offer ->
        val sku = offer.sku
        val token = offer.offerToken
        if (sku.isBlank() || token.isBlank()) {
            null
        } else {
            AndroidSubscriptionOfferInput(sku = sku, offerToken = token)
        }
    }
    ?: emptyList()
val normalizedOffers = subscriptionOffers.takeIf { it.isNotEmpty() }

// Passed to OpenIAP correctly:
val androidProps = RequestSubscriptionAndroidProps(
    // ...
    subscriptionOffers = normalizedOffers  // ✅ Correctly forwarded
)

Potential Issue in Your Code

Looking at your code, I noticed one potential problem:

// Your current code:
const offerToken = productGroup?.subscriptionOfferDetailsAndroid?.find(...)?.offerToken;

The subscriptionOfferDetailsAndroid field is returned as a JSON string, not an array. You need to parse it first:

// Correct approach:
const offers = JSON.parse(productGroup?.subscriptionOfferDetailsAndroid || '[]');
const offerToken = offers.find(
  (offer) => !offer.offerId && offer.basePlanId === productId
)?.offerToken;

Here's how your handleTestPurchase function should look:

const handleTestPurchase = async (productId: string) => {
  try {
    if (ios) {
      await requestPurchase({
        request: { ios: { sku: productId }, android: { skus: [productId] } },
        type: 'subs',
      });
    } else {
      const groupId = productId.startsWith('a') ? 'group_a' : 'group_b';
      const productGroup = subscriptionProducts.find(
        (group) => group.id === groupId
      );

      // ⚠️ Parse the JSON string first!
      const offers = JSON.parse(productGroup?.subscriptionOfferDetailsAndroid || '[]');
      const offerToken = offers.find(
        (offer) => !offer.offerId && offer.basePlanId === productId
      )?.offerToken;

      // Debug: log to verify correct offerToken
      console.log('Selected offerToken:', offerToken);
      console.log('For basePlanId:', productId);

      await requestPurchase({
        request: {
          ios: { sku: productId },
          android: {
            skus: [groupId],
            subscriptionOffers: [{ sku: groupId, offerToken }],
          },
        },
        type: 'subs',
      });
    }
  } catch (error) {
    console.error('Purchase failed:', error);
  }
};

Debugging Steps

Please add these logs to verify the data flow:

// 1. Log subscription products after fetch
useEffect(() => {
  if (subscriptionProducts.length > 0) {
    subscriptionProducts.forEach(product => {
      console.log('Product:', product.id);
      console.log('Offers (raw):', product.subscriptionOfferDetailsAndroid);
      console.log('Offers (parsed):', JSON.parse(product.subscriptionOfferDetailsAndroid || '[]'));
    });
  }
}, [subscriptionProducts]);

// 2. Log purchase result
onPurchaseSuccess: async (purchase) => {
  console.log('Full purchase:', JSON.stringify(purchase, null, 2));
}

// 3. Log active subscriptions
const activeSubs = await getActiveSubscriptions();
console.log('Active subscriptions:', JSON.stringify(activeSubs, null, 2));

Key fields to check in activeSubscriptions

Field Description
currentPlanId The basePlanId of the purchased subscription
basePlanIdAndroid Android-specific base plan identifier
productId The subscription group ID

Next Steps

  1. Verify JSON parsing: Make sure you're parsing subscriptionOfferDetailsAndroid as shown above
  2. Share full logs: If the issue persists, please provide the console output from the debugging steps
  3. Update version: Consider updating to react-native-iap v14.4.46 (latest) with OpenIAP Google 1.3.7

Please let me know the results after adding the debug logs!

Related Links

hyochan avatar Nov 26 '25 00:11 hyochan

@hyochan

The issue now is that when I make a payment, it works fine, but when I purchase an active subscription with getActiveSubscriptions, a different item is downloaded instead of the one I purchased.

I'm experiencing the same issue. Even though I keep paying for different items, when I check the items I paid for with getActiveSubscriptions, I keep getting strange items. When I check my order history in the Google Play Console, I see that the purchase was successful. Please check the getActiveSubscriptions method.

w3Enzo avatar Nov 26 '25 12:11 w3Enzo

On Android, getActiveSubscriptions and onPurchaseSuccess return the first basePlanId in the subscription group instead of the actual purchased plan.

Important: The purchase itself is correct (verified in Google Play Console). Only the basePlanId/currentPlanId field in the response is inaccurate.

Root Cause: Google Play Billing API Limitation

Google Play Billing API's Purchase object does NOT include basePlanId information:

// What Google returns in Purchase object:
data class Purchase(
    val productId: String,      // subscription group ID (e.g., "group_a")
    val purchaseToken: String,
    val orderId: String,
    // ... NO basePlanId field!
)

When a subscription group has multiple base plans (weekly, monthly, yearly), there is no way to determine which specific plan was purchased from the client-side Purchase object alone.

What This Means

Field Accuracy
productId ✅ Correct (subscription group ID)
purchaseToken ✅ Correct
isActive ✅ Correct
transactionId ✅ Correct
currentPlanId / basePlanIdAndroid ❌ May be incorrect (returns first plan)

Recommended Solution

Track the basePlanId yourself during the purchase flow:

// 1. Store basePlanId BEFORE calling requestPurchase
let purchasedBasePlanId: string | null = null;

const handlePurchase = async (basePlanId: string) => {
  const offers = JSON.parse(product.subscriptionOfferDetailsAndroid || '[]');
  const offer = offers.find(o => o.basePlanId === basePlanId && !o.offerId);

  // Store it before purchase
  purchasedBasePlanId = basePlanId;

  await requestPurchase({
    request: {
      android: {
        skus: [subscriptionGroupId],
        subscriptionOffers: [{ sku: subscriptionGroupId, offerToken: offer.offerToken }],
      },
    },
    type: 'subs',
  });
};

// 2. Use YOUR tracked value in onPurchaseSuccess
onPurchaseSuccess: async (purchase) => {
  // DON'T use purchase.currentPlanId - it may be wrong!
  const actualBasePlanId = purchasedBasePlanId;

  // Save to your backend
  await saveToBackend({
    purchaseToken: purchase.purchaseToken,
    basePlanId: actualBasePlanId,  // Use YOUR tracked value
    productId: purchase.productId,
  });
}

// 3. For getActiveSubscriptions after app restart,
//    use your server data instead of relying on currentPlanId

Why Library-Level Fix Is Not Possible

  1. Google API limitation: Purchase object doesn't contain basePlanId
  2. Session-only tracking: We could track during requestPurchaseonPurchasesUpdated, but this data is lost on app restart
  3. No persistent storage in library: IAP library shouldn't manage app-specific persistent storage

Server-Side Verification (Recommended for Production)

For accurate basePlanId after app restart, use Google Play Developer API on your server:

POST https://androidpublisher.googleapis.com/androidpublisher/v3/applications/{packageName}/purchases/subscriptionsv2/tokens/{purchaseToken}

This returns the actual basePlanId from Google's servers.

Related Links

hyochan avatar Nov 26 '25 13:11 hyochan