rc-plan-changes

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Plan Changes on Android with RevenueCat

在Android上使用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.
当用户已有活跃的Google Play订阅,且你需要将其转移至不同SKU(升级、降级、跨级或试用转换)时,可使用此技能。RevenueCat通过单一的
PurchaseParams
构建器暴露Google的替换模式,并在服务器端解析
linkedPurchaseToken
链,因此你无需编写令牌链相关代码。

Phase 1: Preconditions

阶段1:前提条件

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.
在发起套餐变更前,请确认以下事项:
  • 用户恰好拥有一个你打算替换的活跃Google Play订阅。
  • 你已从
    Purchases.sharedInstance.awaitCustomerInfo()
    获取最新的
    CustomerInfo
    ,或从最近的监听器回调中获取缓存值。
  • 你已从
    offerings.current
    解析出目标
    Package
    (参考
    fetch-offerings
    技能)。
  • 项目类路径中已通过RevenueCat SDK引入Google Play Billing Library 7+版本。
若用户无活跃订阅,请跳过此技能。对于首次购买,请使用
make-purchase
技能。

Phase 2: Plan (pick a replacement mode)

阶段2:规划(选择替换模式)

GoogleReplacementMode
maps one to one onto Google's billing modes. Pick based on the user intent:
ScenarioModeBilling 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.
GoogleReplacementMode
与Google的计费模式一一对应,请根据用户需求选择:
场景模式计费效果
标准升级(月度转年度)
WITH_TIME_PRORATION
立即切换,剩余时长按比例折算
升级,保留现有计费日期
CHARGE_PRORATED_PRICE
立即切换,按比例收取当前费用
切换至或切换出自预付费套餐
CHARGE_FULL_PRICE
立即切换,全额收取当前费用
免费试用期间升级
CHARGE_PRORATED_PRICE
立即切换,按比例收取当前费用
降级(年度转月度)
DEFERRED
切换将在下一次续订时生效
请勿在试用升级时默认使用
WITHOUT_PRORATION
WITHOUT_PRORATION
会立即应用新套餐,但直到下一次续订才会收费,这会让用户免费获得未付费的高级权限。请使用
CHARGE_PRORATED_PRICE
当场收取升级费用。
若未设置模式,
PurchaseParams
默认使用
WITHOUT_PRORATION
。请每次都显式设置模式。
DEFERRED
仅适用于降级。Google会拒绝延迟升级请求。

Phase 3: Execute

阶段3:执行

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.
CustomerInfo
中获取
currentProductId
。硬编码SKU会在用户切换套餐后失效。
kotlin
val customerInfo = Purchases.sharedInstance.awaitCustomerInfo()

// activeSubscriptions条目格式为"productId:basePlanId",移除基础套餐后缀
val currentProductId = customerInfo.activeSubscriptions
    .firstOrNull()
    ?.substringBefore(":")
    ?: return  // 无活跃订阅,跳转至make-purchase流程

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会反映新的订阅信息
} catch (e: PurchasesTransactionException) {
    if (!e.userCancelled) showError(e.error.message)
}
关于
oldProductId
的注意事项:
  • 仅传入订阅产品ID。若你传入
    "basic_monthly:monthly_plan"
    ,SDK会自动移除
    :monthly_plan
    ,但自行截取会让意图更清晰。
  • CustomerInfo.activeSubscriptions
    使用
    productId:basePlanId
    格式。
    substringBefore(":")
    可帮你获取正确的值。

Phase 4: Verify

阶段4:验证

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.
挂起调用返回后,读取更新后的
CustomerInfo
  • customerInfo.activeSubscriptions
    现在包含新的
    productId:basePlanId
  • customerInfo.entitlements["pro"]?.isActive
    在切换过程中始终为
    true
    ;请勿根据SKU字符串控制UI权限。
  • 对于
    DEFERRED
    模式,
    activeSubscriptions
    在下一次续订前仍会显示旧产品。RevenueCat会在服务器端跟踪待处理的切换,并在Google发送续订RTDN后更新权益状态。
你无需编写
linkedPurchaseToken
遍历代码。RevenueCat会在服务器端处理链解析,标记旧令牌为已替换,并将两个令牌关联到同一个应用用户ID。客户端代码只需读取并信任权益信息即可。

Common mistakes

常见错误

MistakeFix
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 stateRead
entitlements[...]?.isActive
instead
错误修复方案
oldProductId
硬编码为常量
customerInfo.activeSubscriptions.firstOrNull()?.substringBefore(":")
中获取
"productId:basePlanId"
作为
oldProductId
传入
使用
substringBefore(":")
截取掉基础套餐部分
在试用升级时使用
WITHOUT_PRORATION
使用
CHARGE_PRORATED_PRICE
即时向用户收费
在升级时使用
DEFERRED
DEFERRED
仅适用于降级;Google会拒绝延迟升级请求
编写后端代码处理
linkedPurchaseToken
RevenueCat已在服务器端完成此操作,请删除相关代码
通过读取SKU来决定UI状态改为读取
entitlements[...]?.isActive

References

参考资料