当前订阅到期前升级时,如何延后新订阅的生效日期?
结论
不要在服务端手动修改订阅的 startTimeMillis 或 expiryTimeMillis。Google Play 没有提供可任意调整这些时间的 API。
发起订阅变更时,应当:
- 传入当前订阅的
purchaseToken,让 Google Play 知道新订阅用于替换现有订阅,并非一份独立订阅。 - 使用
ReplacementMode.DEFERRED。 - 在当前计费周期结束前,继续保留旧订阅的权益。
- 旧订阅到期后,由 Google Play 自动启用新订阅并扣费。
如果没有传入旧订阅的 purchaseToken,Google Play 会将新购买视为另一份独立订阅。两份订阅可能同时有效,造成问题中提到的权益重叠。
为什么会发生订阅重叠
Google Play 不会根据商品名称自动判断订阅升级关系。即使两个商品分别代表”月订阅”和”三个月订阅”,直接使用普通购买流程时,它仍可能把两次购买当作彼此独立的订阅。
一次完整的升级流程需要明确提供:
- 当前有效订阅的
purchaseToken。 - 用户准备购买的新订阅商品或 base plan。
- 订阅替换模式。
DEFERRED 表示延迟替换。旧订阅保持有效,到期时间不变;新订阅等旧订阅到期后再生效。
以问题中的时间为例:
- 月订阅从 1 月 1 日开始,原计划在 2 月 1 日左右续订。
- 用户在 1 月 15 日选择三个月订阅。
- 使用
DEFERRED后,月订阅仍然有效,直到原计费周期结束。 - 三个月订阅在旧周期结束时开始计费并生效,不会从 1 月 15 日立即开始。
具体时间应以 Google Play 返回的时间戳为准,不要把每个”月”都按固定的 30 天计算。
Billing Library 实现示例
下面的代码使用较新的 Google Play Billing Library ProductDetails API。项目中实际可用的常量取决于当前使用的 Billing Library 版本。
fun launchDeferredSubscriptionChange(
activity: Activity,
billingClient: BillingClient,
oldPurchaseToken: String,
newProductDetails: ProductDetails,
newOfferToken: String
): BillingResult {
val productDetailsParams =
BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(newProductDetails)
.setOfferToken(newOfferToken)
.build()
val subscriptionUpdateParams =
BillingFlowParams.SubscriptionUpdateParams.newBuilder()
.setOldPurchaseToken(oldPurchaseToken)
.setSubscriptionReplacementMode(
BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.DEFERRED
)
.build()
val billingFlowParams =
BillingFlowParams.newBuilder()
.setProductDetailsParamsList(listOf(productDetailsParams))
.setSubscriptionUpdateParams(subscriptionUpdateParams)
.build()
return billingClient.launchBillingFlow(activity, billingFlowParams)
}
其中必须正确设置旧订阅的 token:
.setOldPurchaseToken(oldPurchaseToken)
同时将替换模式设为 DEFERRED:
.setSubscriptionReplacementMode(
BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.DEFERRED
)
oldPurchaseToken 必须来自用户当前有效的旧订阅。不能传入新订阅的 token,也不能用 orderId 代替。
较旧版本的 Billing Library 可能使用以下接口实现相同功能:
BillingFlowParams.ProrationMode.DEFERRED
新版本正逐步用 SubscriptionUpdateParams.ReplacementMode 取代旧接口。没有必要为了照搬示例而降级依赖,应按项目当前的 Billing Library 版本选择 API。
服务端应该如何处理
客户端完成购买流程后,服务端仍需通过 Google Play Developer API 验证购买状态,并以 Google Play 返回的数据为准。
服务端至少需要保存:
- 用户账户标识。
- 订阅商品及 base plan。
- 当前
purchaseToken。 - 到期时间。
- 自动续订状态。
- 新旧订阅之间的关联。
- 实时开发者通知事件的处理结果。
采用延迟替换时,不能在用户点击升级后立即移除旧订阅权益。服务端应按照旧订阅的到期时间继续授权,收到新订阅生效或续订通知后,再切换到新方案。
查询订阅时,如果项目使用较新的接口,可优先考虑 purchases.subscriptionsv2.get。旧版响应可能包含:
{
"startTimeMillis": "1535366609722",
"expiryTimeMillis": "1535368525233",
"autoRenewing": true
}
这些字段表示 Google Play 管理的开始时间、到期时间和自动续订状态。它们是查询结果,不能通过更新请求自由修改。
时间戳可以按毫秒转换,例如:
val expiryInstant = Instant.ofEpochMilli(expiryTimeMillis.toLong())
判断用户是否仍有权益时,不能只检查 autoRenewing。用户取消自动续订后,autoRenewing 可能变为 false,但在 expiryTimeMillis 之前,通常仍享有已经支付的订阅权益。服务端还要结合购买状态、到期时间、暂停、宽限期、账号保留期和撤销等状态进行判断。
是否需要取消第一个订阅
通常不需要,也不应提前单独取消旧订阅。
购买新方案时,只要通过订阅替换流程传入旧订阅的 purchaseToken,Google Play 就会处理旧订阅的续订关系。选择 DEFERRED 后,旧订阅将持续到当前周期结束,再由新订阅接替。
以下几种操作含义不同:
cancel:通常只关闭后续自动续订,不会立即终止当前已付款周期的权益。revoke:可能立即撤销订阅权益,不适合正常的升级或降级。- 退款:这是财务处理,不等同于订阅替换。
如果先取消旧订阅,再把新订阅作为独立商品购买,两笔交易之间不会自动建立可靠的替换关系,还可能造成重复订阅、权益中断或状态同步困难。
注意事项
不要自己计算三个月后的日期
订阅续订日期应由 Google Play 决定。月份长度、时区、付款重试、宽限期、暂停和账号保留等因素都可能影响实际状态。服务端应使用 API 返回的到期时间,并根据实时开发者通知更新权益。
确认两个方案支持订阅替换
新旧方案必须符合 Google Play 对订阅变更的适用条件。在现代订阅模型下,月方案和三个月方案通常应配置为可以相互替换的 subscription/base plan。实际支持范围还取决于商品类型、base plan 类型和项目使用的 Billing Library 版本。
防止用户再次走普通购买入口
升级按钮应先读取当前有效购买,再把对应的 purchaseToken 传入 SubscriptionUpdateParams。如果无法查到旧 token,不应直接改走普通购买流程,否则可能再次创建并行订阅。
做好重复通知和状态延迟处理
购买回调和实时开发者通知可能重复、延迟或乱序到达。服务端必须以幂等方式处理这些事件,并在重要状态发生变化后重新调用 Google Play Developer API 验证,不能只依赖客户端上报。
这个需求不需要修改新订阅的日期。正确的处理方式是使用 Google Play 的延迟订阅替换机制,传入旧订阅的 purchaseToken,并选择 ReplacementMode.DEFERRED。这样,新方案会在旧订阅周期结束后接续生效。
备注:内容仅供参考。
