Claude
Skills
Sign in
Back

rc-plan-changes

Included with Lifetime
$97 forever

Use this skill when implementing subscription upgrades, downgrades, and plan switches via RevenueCat on Android. Covers GoogleProductChangeInfo, picking a Google replacement mode, and letting the SDK manage linkedPurchaseToken chaining.

Backend & APIs

What this skill does


# Plan Changes on Android with RevenueCat

You use this skill when a user already has an active Google Play subscription and you need to move them to a different SKU (upgrade, downgrade, cross-grade, or trial conversion). RevenueCat exposes Google's replacement modes through a single `PurchaseParams` builder and resolves the `linkedPurchaseToken` chain server side so you do not write token chaining code.

## Phase 1: Preconditions

Confirm the following before invoking a plan change:

- The user has exactly one active Google Play subscription you intend to replace.
- You have a fresh `CustomerInfo` from `Purchases.sharedInstance.awaitCustomerInfo()` or a cached value from a recent listener callback.
- You have the target `Package` resolved from `offerings.current` (see `fetch-offerings` skill).
- Google Play Billing Library 7+ is on the classpath via the RevenueCat SDK.

Skip this skill if the user has no active subscription. For a fresh purchase, use `make-purchase` instead.

## Phase 2: Plan (pick a replacement mode)

`GoogleReplacementMode` maps one to one onto Google's billing modes. Pick based on the user intent:

| Scenario | Mode | Billing effect |
|---|---|---|
| Standard upgrade (monthly to annual) | `WITH_TIME_PRORATION` | Immediate switch, remaining time credited |
| Upgrade, keep the existing billing date | `CHARGE_PRORATED_PRICE` | Immediate switch, prorated charge now |
| Switch to or from a prepaid plan | `CHARGE_FULL_PRICE` | Immediate switch, full charge now |
| Upgrade during an active free trial | `CHARGE_PRORATED_PRICE` | Immediate switch, prorated charge now |
| Downgrade (annual to monthly) | `DEFERRED` | Switch applies at next renewal |

Do not default to `WITHOUT_PRORATION` for trial upgrades. `WITHOUT_PRORATION` applies the new plan immediately but charges nothing until the next renewal, which gives the user free premium access they did not pay for. Use `CHARGE_PRORATED_PRICE` to charge the upgrade price on the spot.

If you set no mode, `PurchaseParams` defaults to `WITHOUT_PRORATION`. Set the mode explicitly every time.

`DEFERRED` is valid only for downgrades. Google rejects deferred upgrades.

## Phase 3: Execute

Derive `currentProductId` from `CustomerInfo`. Hardcoded SKUs break when a user has migrated between plans.

```kotlin
val customerInfo = Purchases.sharedInstance.awaitCustomerInfo()

// activeSubscriptions entries are "productId:basePlanId", strip the base plan suffix
val currentProductId = customerInfo.activeSubscriptions
    .firstOrNull()
    ?.substringBefore(":")
    ?: return  // nothing active, route to make-purchase instead

val newPackage = offerings.current
    ?.availablePackages
    ?.firstOrNull { it.identifier == "premium_annual_package" }
    ?: return

val params = PurchaseParams.Builder(activity, newPackage)
    .googleProductChangeInfo(
        GoogleProductChangeInfo(
            oldProductId = currentProductId,
            replacementMode = GoogleReplacementMode.WITH_TIME_PRORATION,
        )
    )
    .build()

try {
    val result = Purchases.sharedInstance.awaitPurchase(params)
    // result.customerInfo reflects the new subscription
} catch (e: PurchasesTransactionException) {
    if (!e.userCancelled) showError(e.error.message)
}
```

Notes on `oldProductId`:

- Pass the subscription product ID only. If you pass `"basic_monthly:monthly_plan"`, the SDK strips `:monthly_plan` for you, but the intent is clearer when you slice it yourself.
- `CustomerInfo.activeSubscriptions` uses the `productId:basePlanId` shape. `substringBefore(":")` gives you the correct value.

## Phase 4: Verify

After the suspending call returns, read the updated `CustomerInfo`:

- `customerInfo.activeSubscriptions` now contains the new `productId:basePlanId`.
- `customerInfo.entitlements["pro"]?.isActive` stays `true` across the switch; do not gate UI on the SKU string.
- For `DEFERRED` mode, `activeSubscriptions` still reports the old product until the next renewal. RevenueCat tracks the pending switch server side and flips the entitlement after Google sends the renewal RTDN.

You do not write `linkedPurchaseToken` traversal code. RevenueCat resolves the chain, marks the old token as replaced, and attributes both tokens to the same App User ID. Client code reads entitlements and trusts them.

## Common mistakes

| Mistake | Fix |
|---|---|
| Hardcoding `oldProductId` as a constant | Derive it from `customerInfo.activeSubscriptions.firstOrNull()?.substringBefore(":")` |
| Passing `"productId:basePlanId"` as `oldProductId` | Slice off the base plan with `substringBefore(":")` |
| Using `WITHOUT_PRORATION` for a trial upgrade | Use `CHARGE_PRORATED_PRICE` so the user is charged now |
| Using `DEFERRED` for an upgrade | `DEFERRED` is downgrade only; Google rejects deferred upgrades |
| Writing backend code to follow `linkedPurchaseToken` | RevenueCat does this server side, delete the code |
| Reading the SKU to decide UI state | Read `entitlements[...]?.isActive` instead |

## References

- [Full chapter](https://www.revenuecat.com/guides/revenuecat-android-sdk/subscription-upgrades-and-downgrades)

Related in Backend & APIs