如何为 Android 应用内订阅购买应用 promo code?
结论
Google Play Billing 不支持在购买时把订阅价格从 $200 动态改为 $100。购买流程中的价格必须提前在 Google Play Console 配置。客户端不能修改 ProductDetails 里的价格,也不能向 launchBillingFlow() 传入任意金额。
这个需求可以通过”订阅基础方案(base plan)+ 订阅优惠(offer)“实现:
- 基础方案:每个 billing cycle 收取
$200。 - 优惠方案:第一个 billing cycle 收取
$100。 - 优惠结束后:Google Play 自动按基础方案的
$200续订。 - 自定义 promo code:由业务服务端验证。验证通过后,客户端选择相应优惠的
offerToken发起购买。
如果这里指的是 Google Play Console 生成的官方促销代码,代码通常由 Google Play 的兑换界面处理,不应由应用自行验证。官方促销代码和应用自己维护的优惠码是两套机制。
在 Google Play Console 中配置订阅
假设订阅产品 ID 是:
premium_subscription
可以按以下结构配置。
1. 创建自动续订基础方案
为订阅创建一个 auto-renewing base plan,例如:
Base plan ID: monthly-standard
Price: USD 200
Billing period: 1 month
其中,$200 是后续正常续订的价格。
正式发布时,需要在 Play Console 中分别配置各国家或地区的价格,不要让客户端通过字符串或汇率自行推算最终结算金额。
2. 在基础方案下创建优惠
在这个 base plan 下创建一个 offer,例如:
Offer ID: promo-50-first-cycle
Offer tag: promo50
将优惠的 pricing phase 配置为:
第一个 billing period:基础价格的 50%,即 USD 100
优惠结束后:恢复 base plan 的 USD 200
折扣阶段只持续一次。优惠结束后,客户端不需要重新发起扣款或修改订阅,Google Play 会按基础方案自动续订。
Play Console 的具体字段名称可能随界面更新,但实现方式不变:$100 必须作为 offer 的价格阶段提前配置,不能由应用在运行时计算并覆盖。
自定义 promo code 的验证流程
如果 promo code 由业务方自行设计,例如 WELCOME50,可以采用以下流程:
- 用户在应用中输入 promo code。
- 应用把 promo code 发送给业务服务端。
- 服务端检查:
- 代码是否存在;
- 是否已经过期;
- 当前用户是否有使用资格;
- 是否已达到总使用次数限制;
- 当前账号是否已经使用过;
- 代码对应哪个订阅 offer。
- 验证成功后,服务端返回内部优惠标识,例如
promo50。 - 客户端从 Google Play 返回的
subscriptionOfferDetails中选择带有该 tag 的 offer。 - 使用该 offer 的
offerToken调用launchBillingFlow()。 - 购买完成后,客户端把
purchaseToken发给服务端,由服务端通过 Google Play Developer API 验证。 - 验证成功并确认实际购买的是允许的产品后,服务端再核销 promo code,并完成购买确认。
服务端可以返回:
{
"valid": true,
"productId": "premium_subscription",
"offerTag": "promo50"
}
服务端不应返回 $100,再让客户端把这个金额传给 Google Play。它应返回已经在 Play Console 配置好的优惠标识。
Kotlin 购买示例
下面的示例使用 ProductDetails 和订阅 offerToken,省略 BillingClient 连接、异常处理和服务端请求等外围代码。
查询订阅信息
val product = QueryProductDetailsParams.Product.newBuilder()
.setProductId("premium_subscription")
.setProductType(BillingClient.ProductType.SUBS)
.build()
val params = QueryProductDetailsParams.newBuilder()
.setProductList(listOf(product))
.build()
billingClient.queryProductDetailsAsync(params) { billingResult, queryResult ->
if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
// 处理查询失败
return@queryProductDetailsAsync
}
val productDetails = queryResult.productDetailsList
.firstOrNull { it.productId == "premium_subscription" }
?: return@queryProductDetailsAsync
// 保存或展示 productDetails
}
不同版本的 Google Play Billing Library,其查询结果对象的具体结构可能略有差异。代码应以项目当前使用版本的官方 API 签名为准。
根据服务端返回的 tag 选择优惠
fun findOfferToken(
productDetails: ProductDetails,
authorizedOfferTag: String
): String? {
return productDetails.subscriptionOfferDetails
?.firstOrNull { offer ->
authorizedOfferTag in offer.offerTags
}
?.offerToken
}
验证 promo code 后:
val authorizedOfferTag = "promo50" // 来自服务端验证结果
val offerToken = findOfferToken(
productDetails = productDetails,
authorizedOfferTag = authorizedOfferTag
)
if (offerToken == null) {
// 当前账号、地区或 Play 配置下没有返回这个优惠,不能继续按优惠购买
return
}
使用 offerToken 发起购买
val productDetailsParams =
BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(productDetails)
.setOfferToken(offerToken)
.build()
val billingFlowParams = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(listOf(productDetailsParams))
.build()
val billingResult = billingClient.launchBillingFlow(
activity,
billingFlowParams
)
if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
// 处理无法启动购买流程的情况
}
这段客户端代码没有设置 $100。最终展示和收取的价格完全取决于 offerToken 对应的 Play Console 配置。
检查优惠价格阶段
应用可以读取 pricing phases,在自己的页面上展示优惠信息,但最终价格仍应以 Google Play 购买确认界面为准:
val offer = productDetails.subscriptionOfferDetails
?.firstOrNull { "promo50" in it.offerTags }
offer?.pricingPhases?.pricingPhaseList?.forEach { phase ->
val formattedPrice = phase.formattedPrice
val billingPeriod = phase.billingPeriod
val recurrenceMode = phase.recurrenceMode
val billingCycleCount = phase.billingCycleCount
// 展示或记录价格阶段
}
这个 offer 在理想配置下会包含:
- 一个次数有限的折扣阶段:
$100,持续一个 billing cycle; - 后续的正常续订阶段:
$200,持续到用户取消订阅或订阅终止。
不要只根据价格列表的位置判断阶段,例如默认第一个元素一定是优惠价。还需要检查 billing period、循环次数和 recurrence mode。
如何保证后续按 $200 续订
只要把 $100 配置为基础方案下持续一个周期的优惠阶段,阶段结束后,Google Play 就会自动切换到基础方案价格。
应用不需要:
- 在首个周期结束时修改价格;
- 再次调用购买接口;
- 创建定时扣款任务;
- 保存银行卡并自行扣费。
应用主要需要处理订阅状态,包括续订、取消、暂停、宽限期、账号保留和支付失败。服务端应通过 Google Play Developer API 查询订阅状态,并根据需要处理 Real-time Developer Notifications,不能只凭客户端回调永久开通权益。
购买完成后的服务端校验
客户端收到购买结果后,应把以下信息发送给服务端:
{
"productId": "premium_subscription",
"purchaseToken": "purchase-token-from-google-play",
"promoCodeReservationId": "server-side-reservation-id"
}
服务端至少需要验证:
purchaseToken是否真实有效;- product ID 是否正确;
- 订阅是否处于有效状态;
- purchase token 是否已经处理过;
- 当前购买是否符合这次 promo code 授权;
- 优惠码是否属于当前用户;
- 购买是否已经完成 acknowledgement。
通过全部校验后再核销优惠码。验证 promo code 时,可以先创建一条短期”预占记录”,等购买成功后再正式核销,避免用户打开支付页面后取消购买,却失去优惠资格。
购买处理必须具备幂等性,因为购买回调、重试和服务端通知都可能重复出现。
一个重要的安全限制
offerToken 不是密码。即使应用只在 promo code 验证成功后选择这个 offer,客户端数据仍有可能被分析或篡改。
因此,自定义优惠码不能只依赖下面这样的本地判断:
if (input == "WELCOME50") {
launchDiscountedPurchase()
}
最低限度需要做到:
- 在服务端验证 promo code;
- 由服务端记录每个用户的使用资格;
- 购买后在服务端验证 purchase token;
- 不信任客户端传来的价格、优惠比例或”验证成功”布尔值;
- 在服务端保存优惠授权和最终订单之间的关联。
Play Console 中也应使用符合业务场景的 offer eligibility 配置。需要注意,如果使用 developer-determined eligibility,Google Play 可能主要依赖应用决定是否展示和选择优惠,不一定能替业务方拦截所有来自篡改客户端的尝试。因此,权益发放和优惠码核销仍要由服务端控制。
Google Play 官方促销代码与自定义代码的区别
如果使用 Play Console 提供的 promotion codes:
- 代码由 Google Play 创建和管理;
- 用户通过 Google Play 的兑换入口或购买界面兑换;
- 应用通常不负责验证代码本身;
- 支持的优惠形式、期限和适用范围取决于 Play Console 当前提供的能力;
- 它不等于”输入任意业务代码后,把价格动态乘以 50%”。
如果用户必须在应用自己的输入框中填写 WELCOME50 一类代码,更合适的方案是”服务端验证自定义代码 + 选择预配置 subscription offer”。
不建议的实现方式
以下做法通常不可行,或者容易引发问题:
- 修改
ProductDetails、旧版SkuDetails或其中的价格字段; - 向 Billing API 传入自行计算的
$100; - 创建一个永久价格为
$100的订阅,再期待它自动变成$200; - 在客户端硬编码有效的 promo code;
- 只凭客户端的购买成功回调发放永久权益;
- 绕过 Google Play Billing,自行收取 Android 应用内数字订阅费用。
最后一种方式可能涉及 Google Play 的支付政策。某些国家、地区或特定项目可能存在例外,但不能将其视为通用方案。发布前应按照应用的分发地区和业务资格核对最新政策。
较稳妥的设计是把正常价格配置在 base plan,把首期半价配置在 offer,promo code 只用于判断用户是否有资格选择该 offer。首期价格、后续续订价格、账单和订阅生命周期都由 Google Play 管理。
备注:内容仅供参考。
