Skip to content

Subscription replacement always fails with DEVELOPER_ERROR when subscriptionProductReplacementParams is supplied #370

Description

@Dim2960

Subscription replacement always fails with DEVELOPER_ERROR when subscriptionProductReplacementParams is supplied

Summary

When requestPurchase is called with subscriptionProductReplacementParams, the
resulting BillingFlowParams carries an old purchase token without a replacement
mode
. Play rejects the flow with DEVELOPER_ERROR — "Invalid arguments provided
to the API"
— regardless of the mode requested.

Omitting subscriptionProductReplacementParams works, but then the mode is
hardcoded to CHARGE_FULL_PRICE, so callers have no way to select a replacement
mode at all
.

Environment

expo-iap 5.2.4 (openiap-google 3.2.3) — also verified on 3.3.1
Play Billing 9.1.0
Build Android release, minifyEnabled true
Device Samsung, Android 15
Store real Play, license-tested account

Verified on both 3.2.3 and 3.3.1 (latest published at the time of writing):
the code path below is identical in the two versions, so upgrading does not help.
This is not the R8 issue from #307 — 3.2.3 already contains the typed-call fix
from #309.

Where it comes from

packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt:

val updateParamsBuilder = BillingFlowParams.SubscriptionUpdateParams.newBuilder()
androidArgs.purchaseToken?.takeIf { it.isNotBlank() }?.let {
    updateParamsBuilder.setOldPurchaseToken(it)          // ← old token IS set
}
androidArgs.originalExternalTransactionId?.takeIf { it.isNotBlank() }?.let {
    updateParamsBuilder.setOriginalExternalTransactionId(it)
}

// Developer-billed replacements use only the original external
// transaction ID unless the caller explicitly supplies a mode.
val replacementMode = 5.takeIf {
    !androidArgs.purchaseToken.isNullOrBlank() &&
        androidArgs.originalExternalTransactionId.isNullOrBlank() &&
        androidArgs.subscriptionProductReplacementParams == null   // ← the culprit
}
replacementMode?.let { mode ->
    updateParamsBuilder.setSubscriptionReplacementMode(mode)       // ← never reached
}

val updateParams = updateParamsBuilder.build()
flowBuilder.setSubscriptionUpdateParams(updateParams)              // ← applied anyway

Supplying subscriptionProductReplacementParams makes replacementMode null, so
setSubscriptionReplacementMode is skipped — but setSubscriptionUpdateParams is
still applied with the old purchase token. Play then has a subscription to replace
and no instruction on how to bill it.

The mode is applied at product level
(ProductDetailsParams.Builder.setSubscriptionProductReplacementParams), but that
does not appear to be sufficient for a single-product subscription changing
between two base plans.

Reproduction

Subscription product with two auto-renewing base plans (same billing period,
different prices). Active subscription on base plan A, auto-renew ON.

await requestPurchase({
  type: 'subs',
  request: {
    google: {
      skus: ['sub_example'],
      subscriptionOffers: [{ sku: 'sub_example', offerToken: '<offer token of plan B>' }],
      purchaseToken: '<token of the active subscription>',
      subscriptionProductReplacementParams: {
        oldProductId: 'sub_example',
        replacementMode: 'charge-prorated-price',
      },
    },
  },
});

Actual payload reaching the native side (from adb logcat -s ExpoIap:V, with
setprop log.tag.ExpoIap DEBUG):

requestPurchase payload: {"type":"subs","skus":["sub_example"],
  "purchaseToken":"hidden","originalExternalTransactionId":null,
  "subscriptionOffers":[{"sku":"sub_example","offerToken":"hidden"}],
  "subscriptionProductReplacementParams":{"oldProductId":"sub_example",
                                          "replacementMode":"charge-prorated-price"}}
requestPurchase result: []

The Play sheet opens and closes after ~3 s; the error surfaced to JS is
"Invalid arguments provided to the API" (DEVELOPER_ERROR).

Same result with with-time-proration — the mode itself is not the variable.

Removing subscriptionProductReplacementParams entirely makes the flow succeed,
which confirms the diagnosis: the legacy SubscriptionUpdateParams path works, and
it is the only one that ends up carrying a mode.

Impact

With the current code a caller can have either a working replacement or a
chosen mode, never both:

Sent Result
purchaseToken + subscriptionProductReplacementParams DEVELOPER_ERROR
purchaseToken alone works, but always CHARGE_FULL_PRICE (hardcoded 5)

CHARGE_FULL_PRICE is not a safe default for every case:

  • on a downgrade it charges the user immediately for a cheaper plan;
  • on an upgrade between base plans of the same product, Play treats it as the
    same entitlement and carries the remaining time over as raw duration, so the
    user gets time on the expensive plan at the cheap plan's price.

Concretely, on a real store with a 12-month product: subscribing to the cheap base
plan and upgrading immediately yields two years of the expensive plan for the
price of one plus the cheap one
.

Suggested fix

Let the caller's mode reach the legacy builder, falling back to the current default:

val replacementMode = androidArgs.subscriptionProductReplacementParams
        ?.replacementMode
        ?.toLegacyReplacementModeConstant()   // SubscriptionUpdateParams.ReplacementMode values
    ?: 5.takeIf {
        !androidArgs.purchaseToken.isNullOrBlank() &&
            androidArgs.originalExternalTransactionId.isNullOrBlank()
    }

Note that this needs a mapping to BillingFlowParams.SubscriptionUpdateParams.ReplacementMode,
whose numeric values differ from
ProductDetailsParams.SubscriptionProductReplacementParams.ReplacementMode — the very
distinction raised in #71.

Whether the product-level parameters should then still be set for a single-product
subscription is the open question; the legacy path alone is known to work.

I am happy to open a PR if the direction looks right.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions