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 Constant | New ReplacementMode Constant | Behavior |
|---|---|---|
IMMEDIATE_WITH_TIME_PRORATION | WITH_TIME_PRORATION | Upgrade takes effect immediately. Remaining time is credited. |
IMMEDIATE_AND_CHARGE_PRORATED_PRICE | CHARGE_PRORATED_PRICE | Upgrade takes effect immediately. User is charged a prorated amount for the difference. |
IMMEDIATE_WITHOUT_PRORATION | WITHOUT_PRORATION | Upgrade takes effect immediately. No financial credit given. |
DEFERRED | DEFERRED | The new plan takes effect when the old plan expires. |
IMMEDIATE_AND_CHARGE_FULL_PRICE | CHARGE_FULL_PRICE | Upgrade 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.
- Buy the "Monthly" plan.
- Wait 5 minutes (test renewal cycle).
- Trigger the upgrade code to "Annual".
- 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.