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

[Android] Isssue: "replacementModeAndroid: 6", Google Play message: "Something went wrong on our end. Please try again."

Open arseni-siniauski opened this issue 3 months ago • 7 comments

Image Image

Issue

hi, one here is my code for buying a subscription, upgrade/downgrade is already enabled here (the problem occurs with Android during downgrade) one, it seems like I did everything according to the documentation, but every time I try to downgrade to replacementModeAndroid: 6, I get this screenshot maybe I did something wrong or on the contrary I have to finalize something. I will also attach a second screenshot of the organization of my subscriptions (maybe I got something wrong here?)

Buy subscription code

const buySubscription = async (isUpgradingPlan: boolean) => {
if (!selectedPlanId) return;

if (selectedPlanId === FREE_SUBSCRIPTION_PRODUCT.id) {
  handleBuySubscriptionCallback?.();
  return;
}

setIsPurchaseLoading(true);

try {
  const available = await getAvailablePurchases();
  const alreadyActive = available.some(p => selectedPlanId === p.productId);

  if (alreadyActive) {
    console.log("[IAP] Subscription already active, skipping new purchase");
    setIsPurchaseLoading(false);
    handleBuySubscriptionCallback?.();
    return;
  }

  const activeSubscription =
    available.find(p => p.productId === subscriptionsIAP[0]?.id) ??
    undefined;
  const purchaseTokenAndroid = activeSubscription?.purchaseToken;

  const subscription = subscriptionsIAP[0] as ProductSubscriptionAndroid;
  const androidSelectedPlanId = selectedPlanId.replace("_", "-"); // because android offers are named like this: "premium-month" but ios subscriptions are named like this: "premium_month"

  const replacementModeAndroid = isUpgradingPlan ? 1 : 6;

  const chosenOfferToken =
    subscription?.subscriptionOfferDetailsAndroid?.find(
      offer => offer.basePlanId === androidSelectedPlanId,
    )?.offerToken ?? "";
  const otherOffers =
    subscription?.subscriptionOfferDetailsAndroid
      ?.filter(offer => offer.basePlanId !== androidSelectedPlanId)
      ?.map(offer => ({
        sku: subscription.id,
        offerToken: offer.offerToken,
      })) ?? [];

  console.log("skus:", [androidSelectedPlanId]);
  console.log("offerToken:", chosenOfferToken);
  console.log("purchaseToken:", purchaseTokenAndroid);
  console.log("replacementMode:", replacementModeAndroid);

  await requestPurchase({
    request: {
      ios: { sku: selectedPlanId, appAccountToken: userPublicId },
      android: {
        skus: [subscription.id],
        subscriptionOffers: [
          { sku: subscription.id, offerToken: chosenOfferToken },
          ...otherOffers,
        ],
        purchaseTokenAndroid,
        replacementModeAndroid,
        obfuscatedAccountIdAndroid: userPublicId,
      },
    },
    type: "subs",
  });

  dispatch(getUserInfoThunk());
  dispatch(getUserLimitsLeftThunk());
  handleBuySubscriptionCallback?.();
} catch (error) {
  console.error("Buy subscription error:", error);
} finally {
  setIsPurchaseLoading(false);
}
};

Environment:

react-native-iap: "14.4.19" react-native: "0.81.0" Platforms: "Android"

arseni-siniauski avatar Nov 18 '25 11:11 arseni-siniauski

Which version are you using? You've not filled the appropriate template

hyochan avatar Nov 18 '25 11:11 hyochan

Hi @hyochan, I've updated the "Environment" part, do I need to add anything else?

arseni-siniauski avatar Nov 18 '25 12:11 arseni-siniauski

@arseni-siniauski When attempting to downgrade a subscription using replacementModeAndroid: 6 (DEFERRED), Google Play shows "Something went wrong on our end. Please try again."

Root Cause Analysis

1. Subscription Configuration Issue (Most Likely)

Looking at your Google Play Console screenshot:

  • Product ID: subscription_plans
  • Base Plans: premium-month, pro-month

Issue: The premium-month plan shows "Backward compatibility" badge. This indicates a legacy subscription configuration that may not fully support the DEFERRED replacement mode.

2. Incorrect subscriptionOffers Array

Your current code includes all other offers in the subscriptionOffers array:

const otherOffers = subscription?.subscriptionOfferDetailsAndroid
  ?.filter(offer => offer.basePlanId !== androidSelectedPlanId)
  ?.map(offer => ({ sku: subscription.id, offerToken: offer.offerToken })) ?? [];

await requestPurchase({
  request: {
    android: {
      subscriptionOffers: [
        { sku: subscription.id, offerToken: chosenOfferToken },
        ...otherOffers,  // ❌ This is incorrect
      ],
      // ...
    },
  },
});

Problem: The subscriptionOffers array should only contain the single offer you want to purchase, not all available offers.

3. DEFERRED Mode Limitations

Google Play Billing's DEFERRED (6) mode has specific requirements:

  • Must be within the same subscription product
  • May not work with certain legacy subscription configurations
  • Can fail in sandbox/test environments

Recommended Solution

Fix 1: Remove otherOffers from subscriptionOffers

const buySubscription = async (isUpgradingPlan: boolean) => {
  // ... existing code ...

  const chosenOfferToken = subscription?.subscriptionOfferDetailsAndroid?.find(
    offer => offer.basePlanId === androidSelectedPlanId,
  )?.offerToken ?? "";

  await requestPurchase({
    request: {
      ios: { sku: selectedPlanId, appAccountToken: userPublicId },
      android: {
        skus: [subscription.id],
        subscriptionOffers: [
          { sku: subscription.id, offerToken: chosenOfferToken },
          // ✅ Only include the chosen offer, remove otherOffers
        ],
        purchaseTokenAndroid,
        replacementModeAndroid: isUpgradingPlan ? 1 : 6,
        obfuscatedAccountIdAndroid: userPublicId,
      },
    },
    type: "subs",
  });
};

Fix 2: Try WITHOUT_PRORATION (3) as Alternative

If DEFERRED continues to fail, try using WITHOUT_PRORATION:

const replacementModeAndroid = isUpgradingPlan
  ? 1  // WITH_TIME_PRORATION for upgrades
  : 3; // WITHOUT_PRORATION for downgrades

Fix 3: Check Google Play Console Configuration

  1. Verify both base plans are in the same subscription
  2. Remove "Backward compatibility" if possible - This legacy setting can cause issues with modern replacement modes
  3. Ensure both plans have proper pricing configured for all 174 regions

Android Replacement Modes Reference

Value Mode Description
1 WITH_TIME_PRORATION Immediate change with prorated credit (default for upgrades)
2 CHARGE_PRORATED_PRICE Immediate change with prorated charge (upgrade only)
3 WITHOUT_PRORATION Immediate change, no proration
5 CHARGE_FULL_PRICE Immediate change, charge full price
6 DEFERRED Change takes effect at next renewal (recommended for downgrades)

Additional Debugging Steps

  1. Check logcat for detailed error:

    adb logcat | grep -i billing
    
  2. Verify purchaseToken is valid:

    console.log("purchaseToken:", purchaseTokenAndroid);
    // Ensure this is not null/undefined
    
  3. Test with a fresh subscription:

    • Create a new test account
    • Purchase pro-month first
    • Then attempt downgrade to premium-month
  4. Try on a real device:

    • Some billing features don't work correctly on emulators

Related Documentation

hyochan avatar Nov 20 '25 05:11 hyochan

Image

@hyochan hi, thanks for the detailed answer, only today I was able to test all the steps. I managed to remove the "Backward compatibility" for my subscription via the Google Developer API. but unfortunately it didn't help with solving the problem of "Something went wrong on our end. Please try again" I also checked the operation of "Fix 2: Try WITHOUT_PRORATION (3) as Alternative", it worked, but this is not the flow that I expect, I expect that the subscription (lower in price) after calling downgrade, will wait for the end of the current (higher in price) I will be glad of any help, thank you in advance

Updated code

 const buySubscription = async (isUpgradingPlan: boolean) => {
    if (!selectedPlanId) return;

    if (selectedPlanId === FREE_SUBSCRIPTION_PRODUCT.id) {
      handleBuySubscriptionCallback?.();
      return;
    }

    setIsPurchaseLoading(true);

    try {
      const available = await getAvailablePurchases();
      const alreadyActive = available.some(p => selectedPlanId === p.productId);

      if (alreadyActive) {
        console.log("[IAP] Subscription already active, skipping new purchase");
        setIsPurchaseLoading(false);
        handleBuySubscriptionCallback?.();
        return;
      }

      const activeSubscription =
        available.find(p => p.productId === subscriptionsIAP[0]?.id) ??
        undefined;
      const purchaseTokenAndroid = activeSubscription?.purchaseToken;

      const subscription = subscriptionsIAP[0] as ProductSubscriptionAndroid;
      const androidSelectedPlanId = selectedPlanId.replace("_", "-"); // because android offers are named like this: "premium-month" but ios subscriptions are named like this: "premium_month"

      const replacementModeAndroid = isUpgradingPlan ? 1 : 6;

      const chosenOfferToken =
        subscription?.subscriptionOfferDetailsAndroid?.find(
          offer => offer.basePlanId === androidSelectedPlanId,
        )?.offerToken ?? "";

      console.log("skus:", [androidSelectedPlanId]);
      console.log("offerToken:", chosenOfferToken);
      console.log("purchaseToken:", purchaseTokenAndroid);
      console.log("replacementMode:", replacementModeAndroid);

      await requestPurchase({
        request: {
          ios: { sku: selectedPlanId, appAccountToken: userPublicId },
          android: {
            skus: [subscription.id],
            subscriptionOffers: [
              { sku: subscription.id, offerToken: chosenOfferToken },
            ],
            purchaseTokenAndroid,
            replacementModeAndroid,
            obfuscatedAccountIdAndroid: userPublicId,
          },
        },
        type: "subs",
      });

      dispatch(getUserInfoThunk());
      dispatch(getUserLimitsThunk());
      dispatch(getUserLimitsLeftThunk());
      handleBuySubscriptionCallback?.();
    } catch (error) {
      console.error("Buy subscription error:", error);
    } finally {
      setIsPurchaseLoading(false);
    }
  };

Additional info:

Logs from Real device with debug build:

2025-11-25 19:10:03.248 14193-14709 RnIap                   com.myappmyapp                      D  getAvailablePurchases result: [{"id":"GPA.3331-2907-6590-42248","sku":"subscription_plans"}]
2025-11-25 19:10:03.371 14193-14300 ReactNativeJS           com.myappmyapp                      I  'skus:', [ 'premium-month' ]
2025-11-25 19:10:03.371 14193-14300 ReactNativeJS           com.myappmyapp                      I  'offerToken:', '<token>'
2025-11-25 19:10:03.372 14193-14300 ReactNativeJS           com.myappmyapp                      I  'purchaseToken:', '<token>'
2025-11-25 19:10:03.373 14193-14300 ReactNativeJS           com.myappmyapp                      I  'replacementMode:', 6
2025-11-25 19:10:03.374 14193-14709 RnIap                   com.myappmyapp                      D  requestPurchase payload: {"androidSkus":["subscription_plans"],"hasIOS":false}
2025-11-25 19:10:03.375 14193-14244 RnIap                   com.myappmyapp                      D  initConnection payload: null
2025-11-25 19:10:03.375 14193-14244 RnIap                   com.myappmyapp                      D  initConnection result: true
2025-11-25 19:10:03.377 14193-14709 RnIap                   com.myappmyapp                      D  requestPurchase.native payload: {"skus":["subscription_plans"],"type":"subs","offerCount":1}
2025-11-25 19:10:03.395 16299-16310 Finsky                  com.android.vending                  I  [153] com.myappmyapp: Account from first account - [GbxKmeOMXdtZZM8LVrxy5zF5O4OGSC4bdMv2FWIEn_Y]
2025-11-25 19:10:03.396 16299-16310 Finsky                  com.android.vending                  I  [153] Billing preferred account via installer for com.myappmyapp: [GbxKmeOMXdtZZM8LVrxy5zF5O4OGSC4bdMv2FWIEn_Y]
2025-11-25 19:10:03.397 16299-16310 Finsky                  com.android.vending                  W  [153] Got api version 27 for account: [GbxKmeOMXdtZZM8LVrxy5zF5O4OGSC4bdMv2FWIEn_Y]
2025-11-25 19:10:03.420 14193-14193 unknown:BridgelessReact com.myappmyapp                      W  ReactHost{0}.onUserLeaveHint(activity)
2025-11-25 19:10:03.421 14193-14193 FirebaseSessions        com.myappmyapp                      D  App backgrounded on com.myappmyapp
2025-11-25 19:10:03.422 14193-14193 unknown:BridgelessReact com.myappmyapp                      W  ReactHost{0}.onHostPause(activity)
2025-11-25 19:10:03.425 14193-14193 unknown:BridgelessReact com.myappmyapp                      W  ReactContext.onHostPause()
2025-11-25 19:10:03.425 14193-14193 InCallManager           com.myappmyapp                      D  onPause()
2025-11-25 19:10:03.461 14193-14193 FirebaseSessions        com.myappmyapp                      D  App foregrounded on com.myappmyapp
2025-11-25 19:10:03.476 14193-14193 BufferQueueConsumer     com.myappmyapp                      D  [](id:377100000017,api:0,p:-1,c:14193) connect: controlledByApp=false
2025-11-25 19:10:03.481 14193-14252 BLASTBufferQueue        com.myappmyapp                      D  [VRI[ProxyBillingActivity]#23](f:0,a:1) acquireNextBufferLocked size=1080x2400 mFrameNumber=1 applyTransaction=true mTimestamp=182987902718444(auto) mPendingTransactions.size=0 graphicBufferId=60958470832220 transform=0
2025-11-25 19:10:03.481 14193-14252 Parcel                  com.myappmyapp                      W  Expecting binder but got null!
2025-11-25 19:10:03.483 14193-14193 FirebaseSessions        com.myappmyapp                      D  App backgrounded on com.myappmyapp
2025-11-25 19:10:03.542 16299-16299 SBE-DEBUG               com.android.vending                  D  init scroll scenario
2025-11-25 19:10:03.542 16299-16299 SBE-DEBUG               com.android.vending                  D  init scroll scenario
2025-11-25 19:10:03.544 16299-16299 SBE-DEBUG               com.android.vending                  D  init scroll scenario
2025-11-25 19:10:03.544 16299-16299 SBE-DEBUG               com.android.vending                  D  init scroll scenario
2025-11-25 19:10:03.563 16299-16364 PaySecureElementClient  com.android.vending                  D  Felica app not found; returning isSecureElementAvailable = false!
2025-11-25 19:10:03.577 16299-16299 Finsky                  com.android.vending                  I  [2] setOwned true for [email protected]
2025-11-25 19:10:03.588 16299-15197 FA                      com.android.vending                  W  Failed to retrieve Firebase Instance Id
2025-11-25 19:10:03.594 16299-16299 BufferQueueConsumer     com.android.vending                  D  [](id:3fab0000000d,api:0,p:-1,c:16299) connect: controlledByApp=false
2025-11-25 19:10:03.602 16299-8720  skia                    com.android.vending                  D  GrGLMakeAssembledInterface verStr OpenGL ES 3.2 v1.r32p1-01eac0.826ad53ffca045d2012ca444a611fa06
2025-11-25 19:10:03.603 16299-8720  skia                    com.android.vending                  D  GrGLMakeAssembledGLESInterface verStr OpenGL ES 3.2 v1.r32p1-01eac0.826ad53ffca045d2012ca444a611fa06
2025-11-25 19:10:03.603 16299-8720  skia                    com.android.vending                  D  extensions init verString=OpenGL ES 3.2 v1.r32p1-01eac0.826ad53ffca045d2012ca444a611fa06
2025-11-25 19:10:03.606 16299-16303 android.vending         com.android.vending                  W  Missing inline cache for void thb.g(brkp, boolean)
2025-11-25 19:10:03.606 16299-16303 android.vending         com.android.vending                  W  Missing inline cache for void thb.g(brkp, boolean)
2025-11-25 19:10:03.609 16194-16194 BoundBrokerSvc          com.google.android.gms.persistent    D  onRebind: Intent { act=com.google.android.gms.presencemanager.service.START dat=chimera-action:/... cmp=com.google.android.gms/.chimera.PersistentApiService }
2025-11-25 19:10:03.614 16194-16194 BoundBrokerSvc          com.google.android.gms.persistent    D  onRebind: Intent { act=com.google.android.gms.presencemanager.service.INTERNAL_IDENTITY dat=chimera-action:/... cmp=com.google.android.gms/.chimera.PersistentApiService }
2025-11-25 19:10:03.615 16299-8720  BLASTBufferQueue        com.android.vending                  D  [VRI[LockToPortraitUiBuilderHostActivity]#13](f:0,a:1) acquireNextBufferLocked size=1080x2400 mFrameNumber=1 applyTransaction=true mTimestamp=182988036456290(auto) mPendingTransactions.size=0 graphicBufferId=70003671957556 transform=0
2025-11-25 19:10:03.616 16299-8720  Parcel                  com.android.vending                  W  Expecting binder but got null!
2025-11-25 19:10:03.622  1660-1706  ActivityTaskManager     system_server                        I  Displayed com.android.vending/com.google.android.finsky.billing.acquire.LockToPortraitUiBuilderHostActivity: +215ms
2025-11-25 19:10:03.626 16194-15118 NetworkScheduler        com.google.android.gms.persistent    I  (REDACTED) Error inserting %s
2025-11-25 19:10:03.633 16299-16299 ImeFocusController      com.android.vending                  V  onWindowFocus: DecorView@8cb203f[LockToPortraitUiBuilderHostActivity] softInputMode=STATE_UNSPECIFIED|ADJUST_RESIZE|IS_FORWARD_NAVIGATION
2025-11-25 19:10:03.633 16299-16299 ImeFocusController      com.android.vending                  V  Restarting due to isRestartOnNextWindowFocus as true
2025-11-25 19:10:03.633 16299-16299 ImeFocusController      com.android.vending                  D  onViewFocusChanged, view=DecorView@8cb203f[LockToPortraitUiBuilderHostActivity], mServedView=null
2025-11-25 19:10:03.633 16299-16299 ImeFocusController      com.android.vending                  V  checkFocus: view=null next=DecorView@8cb203f[LockToPortraitUiBuilderHostActivity] immDelegate=delegate{37329ce displayId=0} force=true package=<none>
2025-11-25 19:10:03.639  4240-4240  GoogleInpu...hodService com...gle.android.inputmethod.latin  I  GoogleInputMethodService.onFinishInput():3220 
2025-11-25 19:10:03.640  4240-4240  GoogleInpu...hodService com...gle.android.inputmethod.latin  I  GoogleInputMethodService.updateDeviceLockedStatus():2114 repeatCheckTimes = 0, unlocked = true
2025-11-25 19:10:03.640  4240-4240  GoogleInpu...hodService com...gle.android.inputmethod.latin  I  GoogleInputMethodService.onStartInput():1919 onStartInput(EditorInfo{inputType=0x0(NULL) imeOptions=0x0 privateImeOptions=null actionName=UNSPECIFIED actionLabel=null actionId=0 initialSelStart=-1 initialSelEnd=-1 initialCapsMode=0x0 hintText=null label=null packageName=com.android.vending fieldId=-1 fieldName=null extras=null}, false)
2025-11-25 19:10:03.641  4240-4240  GoogleInpu...hodService com...gle.android.inputmethod.latin  I  GoogleInputMethodService.updateDeviceLockedStatus():2114 repeatCheckTimes = 2, unlocked = true

Logs from Real device with "Internal testing" build(logs after calling buySubscription)(adb logcat | grep -i billing):

11-25 19:47:01.865 16299 16131 I Finsky  : [574] Billing preferred account via installer for com.mayappmyapp: [GbxKmeOMXdtZZM8LVrxy5zF5O4OGSC4bdMv2FWIEn_Y]
11-25 19:47:01.996 16299 16131 I Finsky  : [574] Billing preferred account via installer for com.mayappmyapp: [GbxKmeOMXdtZZM8LVrxy5zF5O4OGSC4bdMv2FWIEn_Y]
11-25 19:47:02.016 16299 16131 I Finsky  : [574] Billing preferred account via installer for com.mayappmyapp: [GbxKmeOMXdtZZM8LVrxy5zF5O4OGSC4bdMv2FWIEn_Y]
11-25 19:47:02.108 19211 19273 D BLASTBufferQueue: [VRI[ProxyBillingActivity]#15](f:0,a:1) acquireNextBufferLocked size=1080x2400 mFrameNumber=1 applyTransaction=true mTimestamp=185206529616961(auto) mPendingTransactions.size=0 graphicBufferId=82510616723628 transform=0
11-25 19:47:02.229  1660  1706 I ActivityTaskManager: Displayed com.android.vending/com.google.android.finsky.billing.acquire.LockToPortraitUiBuilderHostActivity: +203ms

arseni-siniauski avatar Nov 25 '25 17:11 arseni-siniauski

@arseni-siniauski

  • Use a single subscriptionOffers entry and pick an offer token that is valid for existing subscribers (skip intro/trial/new-user tags; prefer the plain price phase).
  • Confirm both base plans are active auto-renewing, no legacy/backward-compatibility remnants, and pricing is set in all regions.
  • Pass the currently active sub’s purchaseTokenAndroid; a stale or different token makes DEFERRED fail.
  • If it still shows “Something went wrong…”, log the purchaseErrorListener/BillingClient debugMessage and try a fresh non-intro offer on the target base plan.

hyochan avatar Nov 26 '25 00:11 hyochan

@hyochan

Ok, i've checked all your cases:

  • "Use a single subscriptionOffers entry and pick an offer token that is valid for existing subscribers (skip intro/trial/new-user tags; prefer the plain price phase)" - done, u can see this in previous code implementation

  • "Confirm both base plans are active auto-renewing, no legacy/backward-compatibility remnants, and pricing is set in all regions"- done, i've checked all pricing, regions, legacy/backward-compatibility and it's all done This is one subscription with 2 base plans: Image premium-month settings(all 174 prices are setted(first screenshot)): Image pro-month settings(all 174 prices are setted(first screenshot)): Image

  • "Pass the currently active sub’s purchaseTokenAndroid; a stale or different token makes DEFERRED fail." - done, i've checked my token after completed purchase in console and after get purchaseToken from activity subscription(via getActiveSubscriptions)

const {
    connected,
    subscriptions: subscriptionsIAP,
    fetchProducts: fetchProductsIAP,
    requestPurchase,
    getActiveSubscriptions
  } = useIAP();

 const buySubscription = async (isUpgradingPlan: boolean) => {
      // ... existing code ...

      const available = await getAvailablePurchases();
      
      const activeSubscription =
        available.find(p => p.productId === subscriptionsIAP[0]?.id) ??
        undefined;
      const purchaseTokenAndroid = activeSubscription?.purchaseToken;

      // ... existing code ...
  };

  • "If it still shows “Something went wrong…”, log the purchaseErrorListener/BillingClient debugMessage and try a fresh non-intro offer on the target base plan." - checked and it's shows 'Purchase error', 'Invalid arguments provided to the API'

I also have a few questions.

  1. Could this error be related to the fact that I did not complete the "Basic setup" for the application?Image
  2. I've checked all the replacementModeAndroid for downgrade (with the current higher-priced plan, the user makes a purchase of a lower-priced plan) and only replacementModeAndroid: 6 does not work for me?
  3. Maybe there is an example upgrade /downgrade code in addition to the documentation, with which I can check the operation of the subscription purchase?

I will be glad of any help, thank you

arseni-siniauski avatar Nov 26 '25 13:11 arseni-siniauski

@arseni-siniauski Based on your debugging:

  • Error message: 'Invalid arguments provided to the API'
  • All other replacement modes work (1, 2, 3, 5) - only DEFERRED (6) fails
  • Subscription configuration looks correct - both base plans are active, pricing set for all 174 regions
  • purchaseToken is being retrieved correctly from getAvailablePurchases()

Confirmed Solution: Use replacementModeAndroid: 3 for Downgrades

Since DEFERRED (6) is failing but other modes work, use WITHOUT_PRORATION (3) instead:

const replacementModeAndroid = isUpgradingPlan
  ? 1  // WITH_TIME_PRORATION for upgrades
  : 3; // WITHOUT_PRORATION for downgrades

This is a reliable solution that works in your environment.


Possible Cause: Google Play Console "Basic Setup" Incomplete (Unverified)

Note: This is a possible explanation, not confirmed. Google's official documentation doesn't explicitly state this requirement.

The incomplete "Basic setup" in Google Play Console might affect DEFERRED mode because:

  • DEFERRED involves scheduling future billing changes
  • Google Play may require full app verification for deferred operations
  • Immediate transaction modes (1, 2, 3, 5) have simpler validation

However, we cannot confirm this is the root cause without:

  1. Testing after completing Basic Setup
  2. Finding official Google documentation about this requirement

If You Want to Test This Theory

  1. Complete Google Play Console → Your App → SetupApp content
  2. Complete all required sections (Content rating, Data safety, Target audience, etc.)
  3. Test DEFERRED (6) again after completion

Implementation Examples

Basic Implementation

const buySubscription = async (isUpgradingPlan: boolean) => {
  // ... existing code ...

  const replacementModeAndroid = isUpgradingPlan
    ? 1  // WITH_TIME_PRORATION for upgrades
    : 3; // WITHOUT_PRORATION for downgrades

  await requestPurchase({
    request: {
      android: {
        skus: [subscription.id],
        subscriptionOffers: [
          { sku: subscription.id, offerToken: chosenOfferToken },
        ],
        purchaseTokenAndroid,
        replacementModeAndroid,
        obfuscatedAccountIdAndroid: userPublicId,
      },
    },
    type: "subs",
  });
};

With Fallback Logic (Optional)

If you want to try DEFERRED first and fallback:

const buySubscription = async (isUpgradingPlan: boolean) => {
  const preferredMode = isUpgradingPlan ? 1 : 6; // Try DEFERRED for downgrade
  const fallbackMode = isUpgradingPlan ? 1 : 3;  // Fallback to WITHOUT_PRORATION

  try {
    await requestPurchase({
      request: {
        android: {
          skus: [subscription.id],
          subscriptionOffers: [
            { sku: subscription.id, offerToken: chosenOfferToken },
          ],
          purchaseTokenAndroid,
          replacementModeAndroid: preferredMode,
          obfuscatedAccountIdAndroid: userPublicId,
        },
      },
      type: "subs",
    });
  } catch (error) {
    // If DEFERRED fails with "Invalid arguments", retry with fallback
    if (
      !isUpgradingPlan &&
      error.message?.includes('Invalid arguments')
    ) {
      console.log('DEFERRED mode failed, retrying with WITHOUT_PRORATION');
      await requestPurchase({
        request: {
          android: {
            skus: [subscription.id],
            subscriptionOffers: [
              { sku: subscription.id, offerToken: chosenOfferToken },
            ],
            purchaseTokenAndroid,
            replacementModeAndroid: fallbackMode,
            obfuscatedAccountIdAndroid: userPublicId,
          },
        },
        type: "subs",
      });
    } else {
      throw error;
    }
  }
};

Example Code Reference

Here's the upgrade/downgrade example from our example app:

File: example/screens/SubscriptionFlow.tsx

// From example/screens/SubscriptionFlow.tsx
const handleUpgradeDowngrade = async (
  targetBasePlanId: string,
  isUpgrade: boolean,
) => {
  if (!currentSubscription?.purchaseToken) {
    Alert.alert('Error', 'No active subscription to upgrade/downgrade');
    return;
  }

  const subscription = subscriptions.find(
    (s) => s.id === currentSubscription.productId,
  );
  if (!subscription) return;

  const targetOffer = (
    subscription as ProductSubscriptionAndroid
  ).subscriptionOfferDetailsAndroid?.find(
    (o) => o.basePlanId === targetBasePlanId,
  );

  if (!targetOffer) {
    Alert.alert('Error', 'Target offer not found');
    return;
  }

  try {
    await requestPurchase({
      request: {
        android: {
          skus: [subscription.id],
          subscriptionOffers: [
            {
              sku: subscription.id,
              offerToken: targetOffer.offerToken,
            },
          ],
          purchaseTokenAndroid: currentSubscription.purchaseToken,
          // 1 = WITH_TIME_PRORATION (for upgrades)
          // 3 = WITHOUT_PRORATION (for downgrades)
          replacementModeAndroid: isUpgrade ? 1 : 3,
        },
      },
      type: 'subs',
    });
  } catch (error) {
    console.error('Upgrade/Downgrade failed:', error);
    Alert.alert('Error', 'Failed to change subscription');
  }
};

Key Differences: DEFERRED vs WITHOUT_PRORATION

Aspect DEFERRED (6) WITHOUT_PRORATION (3)
When change applies Next renewal date Immediately
User keeps current plan Yes, until renewal No, switches now
Refund/Credit None needed None given
UX for downgrade Better (user keeps paid features) User loses features immediately
Google Play requirements Stricter validation More lenient

Recommendation

Use replacementModeAndroid: 3 (WITHOUT_PRORATION) for downgrades. This is confirmed to work in your environment.

If you want to investigate further:

  • Try completing Google Play Console Basic Setup and test DEFERRED (6) again
  • Let us know the result so we can update documentation for others

Related Resources

hyochan avatar Nov 26 '25 14:11 hyochan