PAYATHON 2026

如何为 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,可以采用以下流程:

  1. 用户在应用中输入 promo code。
  2. 应用把 promo code 发送给业务服务端。
  3. 服务端检查:
    • 代码是否存在;
    • 是否已经过期;
    • 当前用户是否有使用资格;
    • 是否已达到总使用次数限制;
    • 当前账号是否已经使用过;
    • 代码对应哪个订阅 offer。
  4. 验证成功后,服务端返回内部优惠标识,例如 promo50。
  5. 客户端从 Google Play 返回的 subscriptionOfferDetails 中选择带有该 tag 的 offer。
  6. 使用该 offer 的 offerToken 调用 launchBillingFlow()。
  7. 购买完成后,客户端把 purchaseToken 发给服务端,由服务端通过 Google Play Developer API 验证。
  8. 验证成功并确认实际购买的是允许的产品后,服务端再核销 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 管理。

备注:内容仅供参考。