Skip to main content

Migrating to Google Play Billing Library 7: Fixing ProrationMode Errors

 If you recently updated your Android project dependencies to Google Play Billing Library v7.0.0+, your build likely failed with an unresolved reference error regarding ProrationMode.

The error typically looks like this: Unresolved reference: ProrationMode or Cannot resolve method setReplaceProrationMode.

Google has enforced a mandatory migration deadline for August 2025. This update isn't just a syntax change; it represents a fundamental shift in how the Play Store handles subscription lifecycle management. The ProrationMode enum and its associated setters have been removed entirely in favor of ReplacementMode.

This guide provides the technical root cause, the direct mapping for migration, and a production-ready Kotlin implementation.

Root Cause Analysis: Why ProrationMode Was Removed

To understand the fix, you must understand the architectural change in the underlying API.

In Billing Library v4 and earlier, subscriptions were treated similarly to one-time products (managed products). When a user upgraded or downgraded, the system used ProrationMode to calculate the financial delta.

Starting with Billing Library v5 and solidified in v7, Google moved to a Subscription Model based on Base Plans and Offers. The concept of "Proration" was too narrow because swapping a subscription involves more than just price math; it involves billing cycle resets, entitlement transfers, and renewal date adjustments.

The library now encapsulates these actions under SubscriptionUpdateParams. Consequently, ProrationMode (which focused on price) has been superseded by ReplacementMode (which focuses on the lifecycle behavior of the replacement).

Using the old setReplaceSkusProrationMode method is no longer possible because the underlying AIDL interface expects the new ReplacementMode integers.

The Migration Mapping

You cannot simply rename the class. You must map the logic. Here is the direct translation from the deprecated ProrationMode constants to the new ReplacementMode constants defined inside BillingFlowParams.SubscriptionUpdateParams.

Old ProrationMode ConstantNew ReplacementMode ConstantBehavior
IMMEDIATE_WITH_TIME_PRORATIONWITH_TIME_PRORATIONUpgrade takes effect immediately. Remaining time is credited.
IMMEDIATE_AND_CHARGE_PRORATED_PRICECHARGE_PRORATED_PRICEUpgrade takes effect immediately. User is charged a prorated amount for the difference.
IMMEDIATE_WITHOUT_PRORATIONWITHOUT_PRORATIONUpgrade takes effect immediately. No financial credit given.
DEFERREDDEFERREDThe new plan takes effect when the old plan expires.
IMMEDIATE_AND_CHARGE_FULL_PRICECHARGE_FULL_PRICEUpgrade takes effect immediately. User is charged full price; billing cycle resets.

The Fix: Implementation in Kotlin

Below is a complete, syntactically valid implementation using Billing Library 7.0.0.

1. The Deprecated Approach (Do Not Use)

Previously, you likely configured the BillingFlowParams builder directly with the old purchase token and proration mode.

// DEPRECATED - This will not compile in Billing Client v7
val flowParams = BillingFlowParams.newBuilder()
    .setSkuDetails(skuDetails)
    .setOldSku(oldSku, BillingFlowParams.ProrationMode.IMMEDIATE_WITH_TIME_PRORATION) // REMOVED
    .build()

2. The Modern Approach (Billing Client v7)

In v7, you must create a SubscriptionUpdateParams object and pass it to the BillingFlowParams builder.

import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingFlowParams
import com.android.billingclient.api.BillingFlowParams.SubscriptionUpdateParams
import com.android.billingclient.api.ProductDetails

/**
 * Launches the billing flow for a subscription upgrade/downgrade.
 * 
 * @param activity The active Activity context.
 * @param client The initialized and connected BillingClient.
 * @param newProductDetails The ProductDetails object for the NEW plan.
 * @param offerToken The specific offer token (if applicable for the new plan).
 * @param oldPurchaseToken The purchase token of the subscription being replaced.
 */
fun launchSubscriptionUpdateFlow(
    activity: android.app.Activity,
    client: BillingClient,
    newProductDetails: ProductDetails,
    offerToken: String,
    oldPurchaseToken: String
) {
    // 1. Define how the replacement should be handled
    // mapping: IMMEDIATE_WITH_TIME_PRORATION -> WITH_TIME_PRORATION
    val updateParams = SubscriptionUpdateParams.newBuilder()
        .setOldPurchaseToken(oldPurchaseToken)
        .setSubscriptionReplacementMode(SubscriptionUpdateParams.ReplacementMode.WITH_TIME_PRORATION)
        .build()

    // 2. Define the new product parameters
    val productParams = BillingFlowParams.ProductDetailsParams.newBuilder()
        .setProductDetails(newProductDetails)
        .setOfferToken(offerToken)
        .build()

    // 3. Build the flow parameters, attaching the update logic
    val flowParams = BillingFlowParams.newBuilder()
        .setProductDetailsParamsList(listOf(productParams))
        .setSubscriptionUpdateParams(updateParams)
        .build()

    // 4. Launch the flow
    val responseCode = client.launchBillingFlow(activity, flowParams).responseCode

    if (responseCode != BillingClient.BillingResponseCode.OK) {
        // Handle error (log it, show toast, etc.)
        System.err.println("Billing flow failed to launch: $responseCode")
    }
}

Deep Dive: Critical Implementation Details

Retrieving the Offer Token

In the code above, offerToken is critical. In Billing v7, a single ProductDetails object (a Base Plan) can have multiple Offers (e.g., "Free Trial," "Introductory Price," "Standard").

You must explicitly select which offer token to use. If your base plan has no special offers, you still need to extract the standard offer token from the ProductDetails list.

// Helper to get the first available offer token for a subscription
fun getFirstOfferToken(productDetails: ProductDetails): String {
    return productDetails.subscriptionOfferDetails?.firstOrNull()?.offerToken 
        ?: throw IllegalStateException("No offer details found for subscription")
}

Handling The "Deferred" Mode

If you use ReplacementMode.DEFERRED, the Google Play UI behavior changes significantly. The user does not pay immediately. Instead, the UI confirms that the plan will switch at the next renewal date.

When testing DEFERRED mode, do not expect an immediate purchase token refresh in your onPurchasesUpdated listener. The purchase token remains the same until the switch actually happens.

Common Pitfalls and Edge Cases

1. Missing oldPurchaseToken

If you fail to pass .setOldPurchaseToken() within SubscriptionUpdateParams, Google Play will treat the transaction as a new subscription rather than an upgrade. The user will end up with two active subscriptions simultaneously. Always validate that oldPurchaseToken is not null or empty before building params.

2. Cross-Entitlement Upgrades

Billing Library v7 enforces stricter checks on cross-grade compatibility. Ensure that the oldPurchaseToken belongs to a subscription that is currently active. Attempting to replace an already expired subscription using ReplacementMode will result in a DEVELOPER_ERROR response.

3. Testing with Licensed Testers

You cannot effectively test proration logic with generic test cards (e.g., "Slow test card, decline"). You must use a Gmail account listed in the "License Testing" section of the Play Console.

  1. Buy the "Monthly" plan.
  2. Wait 5 minutes (test renewal cycle).
  3. Trigger the upgrade code to "Annual".
  4. Verify the UI shows the prorated credit.

Conclusion

The migration from ProrationMode to ReplacementMode in Billing Library 7 is mandatory for upcoming Android releases. While the syntax has changed, the logic remains robust. By encapsulating upgrade logic into SubscriptionUpdateParams, your codebase becomes more aligned with Google's backend definition of subscription lifecycles.

Ensure you deploy this change well before the August 2025 enforcement date to prevent disruption in your revenue streams.