Irbisa · Шпаргалка

Встроенные покупки и подписки

StoreKit 2, Play Billing, права доступа, серверная валидация, восстановление, офферы, тестирование

Middle-разработчик · 23 вопроса

  1. 01

    Приложение продаёт месячную подписку, физическую толстовку и запись в барбершоп: что из этого обязано проходить через биллинг стора?

    Только месячная подписка.

  2. 02

    Пройдитесь по разовой покупке через Play Billing Library: какие вызовы происходят и в каком порядке?

    Подключаете один BillingClient, получаете ProductDetails через queryProductDetailsAsync, открываете окно оплаты через launchBillingFlow, получаете результат в PurchasesUpdatedListener, затем проверяете токен на своём сервере и делаете acknowledge или consume.

    val billing = BillingClient.newBuilder(context)  .setListener { result, purchases ->           // every purchase result lands here    if (result.responseCode == BillingResponseCode.OK) purchases?.forEach(::reportToServer)  }  .enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build())  .enableAutoServiceReconnection()              // Billing Library 8+…
  3. 03

    Как устроена подписка в Google Play (subscription, base plan, offer) и что такое offerToken?

    Подписка представляет собой продукт, которым владеет пользователь, base plan задаёт её период оплаты, цену и тип продления, а offer означает пробный период или скидку, привязанные к одному base plan.

  4. 04

    Пользователь купил 500 монет, вечное отключение рекламы и месячную подписку: что каждая из этих трёх покупок обязывает делать клиент по-разному?

    Монеты списывайте (consume) только после того, как сервер их начислил, разблокировку спрашивайте у стора, а не храните, а состояние подписки читайте с вашего бэкенда.

    // Play has two product types (INAPP, SUBS); "consumable" is your decision, not the store'sprivate val consumableIds = setOf("coins_500", "coins_2000") suspend fun settle(purchase: Purchase) {  // PENDING (cash or slow payment method) is not money yet: never grant, never settle  if (purchase.purchaseState != Purchase.PurchaseState.PURCHASED) return…
  5. 05

    В каких состояниях бывает покупка в Play Billing и почему её нужно подтвердить (acknowledge) за три дня?

    Purchase бывает PURCHASED, PENDING или UNSPECIFIED_STATE; подтверждённость хранится в отдельном флаге isAcknowledged.

    override fun onPurchasesUpdated(result: BillingResult, purchases: List<Purchase>?) {  if (result.responseCode != BillingResponseCode.OK) return  purchases?.forEach { p ->    when (p.purchaseState) {      Purchase.PurchaseState.PURCHASED -> scope.launch { settle(p) }      Purchase.PurchaseState.PENDING -> showAwaitingPayment()  // no grant, no acknowledge…
  6. 06

    Пейволл показывает цены через раз, а покупки теряются. Что не так с этим кодом?

    class PaywallViewModel(app: Application) : AndroidViewModel(app) {  val prices = MutableStateFlow<List<ProductDetails>>(emptyList())  private val billing = BillingClient.newBuilder(app)    .setListener { _, _ -> }    .enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build())    .build()  init {    billing.startConnection(object : BillingClientStateListener {      override fun onBillingSetupFinished(result: BillingResult) {}      override fun onBillingServiceDisconnected() {}    })    billing.queryProductDetailsAsync(productQuery()) { _, result ->      prices.value = result.productDetailsList    }  }}

    startConnection асинхронный, поэтому запрос уходит до подключения к сервису и падает с SERVICE_DISCONNECTED.

  7. 07

    Эта кнопка включает премиум, когда launchBillingFlow вернул OK. Что здесь неправильно и какие реальные покупки код пропускает?

    fun onBuyClicked(activity: Activity, lifetime: ProductDetails) {  val params = BillingFlowParams.newBuilder()    .setProductDetailsParamsList(listOf(      BillingFlowParams.ProductDetailsParams.newBuilder()        .setProductDetails(lifetime)        .build()))    .build()   val result = billingClient.launchBillingFlow(activity, params)  if (result.responseCode == BillingResponseCode.OK) {    prefs.edit { putBoolean("is_premium", true) }  }}

    OK от launchBillingFlow значит лишь, что окно оплаты открылось, поэтому премиум получит любой, кто его открыл и закрыл.

  8. 08

    Приложение решает, что разблокировать, по флагу is_premium в SharedPreferences; пиратство оставим в стороне: что ломается у честных платящих пользователей?

    Булев флаг не вмещает четыре вещи, из которых состоит право доступа (чьё оно, до какого момента, с какой платформы и в каком состоянии), поэтому оно ломается у платящих задолго до попыток что-то подделать.

    // What the client is allowed to knowdata class Entitlement(  val productId: String?,  val status: Status,          // ACTIVE, GRACE, ON_HOLD, EXPIRED, NONE  val expiresAt: Instant?,  val willRenew: Boolean,…
  9. 09

    Клиент передаёт на бэкенд идентификатор покупки: что сервер с ним делает и почему проверить подпись на устройстве недостаточно?

    Сервер меняет идентификатор на актуальный взгляд стора на эту покупку.

    // Backend. The client is a courier: it carries an identifier and asserts nothing.suspend fun claimPlay(userId: UserId, productId: String, token: String): Entitlement {  val sub = withContext(Dispatchers.IO) {   // the Google client blocks    play.purchases().subscriptionsv2().get(packageName, token).execute()  }  val entitlement = entitlements.upsert(…
  10. 10

    Продления, возвраты и отмены происходят, пока приложение закрыто: как бэкенд о них узнаёт и как обработчик переживает дубликаты?

    Оба стора шлют события жизненного цикла на зарегистрированный вами эндпоинт: App Store Server Notifications V2 и Real-time Developer Notifications в Play через Pub/Sub.

    // Play RTDN. The message is a doorbell: it says which token changed, nothing more.suspend fun onRtdn(message: PubsubMessage) {  if (processed.contains(message.messageId)) return ack(message)  // at-least-once delivery   val n = Json.decodeFromString<DeveloperNotification>(message.data.toStringUtf8())  val voided = n.voidedPurchaseNotification…
  11. 11

    Пользователь переустановил приложение, нажал «Восстановить», а вечная разблокировка не вернулась, хотя Play считает, что она куплена. Где искать?

    Проверьте, под каким Google-аккаунтом работает Play Store и что это был за продукт: восстановление читает то, чем владеет аккаунт стора, а не то, за что платил ваш аккаунт в приложении.

    suspend fun restore(): RestoreOutcome {  val tokens = buildList {    for (type in listOf(ProductType.INAPP, ProductType.SUBS)) {      val result = billing.queryPurchasesAsync(        QueryPurchasesParams.newBuilder().setProductType(type).build()      )…
  12. 12

    Пользователю вернули деньги через Google Play. Как об этом узнаёт ваш бэкенд и что вы забираете обратно?

    Через voidedPurchaseNotification в RTDN и, для подстраховки, через Voided Purchases API (purchases.voidedpurchases.list).

  13. 13

    Пользователь заплатил через стор, затем входит в другой аккаунт вашего приложения и ждёт, что подписка приедет туда же. Кому достанется право доступа и как это обеспечить?

    Право доступа достаётся первому вашему аккаунту, который заявил покупку стора: платёж принадлежит аккаунту стора, право доступа принадлежит вашему аккаунту, а связь держит уникальный индекс по токену покупки на вашем сервере.

    fun buy(activity: Activity, details: ProductDetails, offerToken: String, userId: String) {  val params = BillingFlowParams.newBuilder()    .setProductDetailsParamsList(listOf(      BillingFlowParams.ProductDetailsParams.newBuilder()        .setProductDetails(details)        .setOfferToken(offerToken)…
  14. 14

    На пейволле написано «7 дней бесплатно», но у части пользователей деньги списываются в первый же день. Что не так с этим кодом?

    fun buyMonthly(activity: Activity, details: ProductDetails) {  val offer = details.subscriptionOfferDetails!!.first()  val params = BillingFlowParams.newBuilder()    .setProductDetailsParamsList(listOf(      BillingFlowParams.ProductDetailsParams.newBuilder()        .setProductDetails(details)        .setOfferToken(offer.offerToken)        .build()))    .build()  billing.launchBillingFlow(activity, params)}

    first() берёт первую запись из списка: это может быть сам base plan или offer другого base plan, а у пользователя без права на пробный период записи с ним нет.

  15. 15

    Как перевести подписчика Play с месячного плана на годовой и что происходит со старой покупкой?

    Купите годовой base plan с режимом замены: внутри одной подписки разрешены только CHARGE_FULL_PRICE (списать годовую цену сейчас) и WITHOUT_PRORATION (переключить сейчас, новую цену заплатить при продлении).

    fun changePlan(  activity: Activity,  yearly: ProductDetails,  offerToken: String,  current: Purchase,) {…
  16. 16

    У подписчика посреди цикла истекает карта. Через какие состояния он пройдёт до потери доступа и что приложение показывает в каждом?

    Между «продлилась» и «пропала» оба стора прогоняют окно восстановления (повторные списания, опциональный grace period и account hold у Play), и доступ сохраняется только на время grace.

    // One vocabulary for both stores; the UI never sees a store-specific stringenum class SubState { ACTIVE, CANCELLED_ACTIVE, GRACE, HOLD, PAUSED, EXPIRED } fun fromPlay(s: SubscriptionPurchaseV2): SubState = when (s.subscriptionState) {  "SUBSCRIPTION_STATE_ACTIVE" ->    if (s.lineItems.first().autoRenewingPlan?.autoRenewEnabled == true) SubState.ACTIVE…
  17. 17

    Пользователь присылает чек: деньги списаны, приложение по-прежнему показывает пейволл, перезапуск ничего не изменил. Как он получит свой контент?

    Начните с номера заказа из чека: оба стора позволяют серверу превратить его в настоящую покупку, поэтому решением будет поиск на сервере и выдача доступа, а не «купите, пожалуйста, ещё раз».

  18. 18

    Найдите баг: на экране оплаты $19.99, а с карты покупателя списывается $19.98.

    data class CartItem(val sku: String, val price: Double, val qty: Int) fun totalInCents(items: List<CartItem>): Long {  val total = items.sumOf { it.price * it.qty }  return (total * 100).toLong()} suspend fun checkout(items: List<CartItem>): PaymentIntent =  paymentsApi.createPaymentIntent(    amount = totalInCents(items),  // the gateway takes minor units    currency = "usd",  )

    price имеет тип Double, и 19.99 * 100 даёт 1998.9999999999998; toLong() отбрасывает дробную часть, а не округляет, поэтому шлюз списывает 1998 центов, то есть на цент меньше, чем показал экран.

  19. 19

    Вы хотите поднять цену подписки на 30%. Что за вас сделает Google Play и когда текущий подписчик обязан согласиться?

    Вы меняете цену base plan: новые покупатели платят сразу, текущие остаются в legacy-когорте, пока вы их не перенесёте.

  20. 20

    Пользователь оформил подписку на iPhone, а потом открыл ваше Android-приложение и сайт. Где на самом деле живёт ответ «подписчик ли он»?

    В вашей собственной базе, по вашему же user id.

    // One row, three writers. Every webhook converges here.data class Entitlement(  val userId: String, val product: String, val state: SubState,  val activeUntil: Instant, val source: Source, val sourceRef: String)…
  21. 21

    Найдите баг: при нестабильной мобильной сети с некоторых покупателей за один заказ списывают деньги дважды.

    suspend fun pay(order: Order): ChargeResult {  repeat(3) { attempt ->    try {      return api.charge(        ChargeRequest(order.id, order.amountCents),        idempotencyKey = UUID.randomUUID().toString(),      )    } catch (e: IOException) {  // timeout or dropped connection      delay(1_000L shl attempt)    }  }  throw PaymentUnknownException(order.id)}

    Каждый повтор отправляет новый UUID в качестве ключа идемпотентности, и шлюз считает каждую попытку новым платежом.

  22. 22

    Как протестировать продления, возврат и отклонённую карту в Google Play, не потратив денег, и чего такое тестирование никогда не поймает?

    С license testers в Play Console, сборкой из тестового трека и тестовыми способами оплаты Play; тестовые подписки продлеваются на ускоренных часах, месяц проходит за 5 минут.

  23. 23

    Продукт хочет A/B-тест трёх цен на пейволле и еженедельный отчёт по результатам. Что для этого должно появиться в приложении и на сервере?

    Цену нельзя задать из ремоут-конфига (цены живут в сторах), поэтому ценовой тест представляет собой серверный выбор между уже существующими product id плюс запись о покупке, которая помнит, какая ветка её породила.

    // Server assigns; the client only renders what the store says those ids cost@Serializabledata class PaywallConfig(val variantId: String, val productIds: List<String>) suspend fun loadPaywall(): Paywall {  val config = runCatching { api.paywallConfig() }.getOrDefault(BUNDLED_DEFAULT)…