​​Привет, я Влад, core разработчик Adapty SDK для встроенных покупок на Android. Это наша третья статья из цикла статей про внедрение покупок на Android. В этой серии мы полностью закрываем вопросы добавления покупок в приложениях в Google Play:

  1. Android in-app purchases, часть 1: конфигурация и добавление в проект

  2. Android in-app purchases, часть 2: инициализация и обработка покупок

  3. Android in-app purchases, часть 3: получение активных покупок и смена подписки. — Вы тут.

  4. Android in-app purchases, часть 4: коды ошибок от Billing Library и как не облажаться с тестированием.

  5. Android in-app purchases, часть 5: серверная валидация покупок.

Cегодня мы рассмотрим еще две важные темы по реализации in-app purchases с Google Play Billing Library. Начнем с получения активных покупок пользователя, то есть действующих подписок и ранее купленных non-consumable продуктов. Купленные consumable-продукты сюда не входят, потому что вы их обменяли на голду законсьюмили, чтобы иметь возможность купить повторно.

 Это нам может пригодиться, если мы, допустим, хотим сделать кнопку покупки такого продукта неактивной. Или, например, написать на ней, что это текущая подписка, если это подписка. Или просто дать пользователю доступ к оплаченным фичам.

Получение списка покупок

В Billing Library для получения активных покупок есть метод queryPurchasesAsync(String skuType, PurchasesResponseListener listener) (раньше использовался синхронный метод queryPurchases(String skuType), но с версии Billing Library 4.0.0 его задепрекейтили). Да, все подобные методы почему-то требуют указания типа продуктов, поэтому получение полного списка снова придется комбинировать из двух запросов.

Дополним наш BillingClientWrapper по аналогии с тем, как мы делали в предыдущих статьях:

interface OnQueryActivePurchasesListener {
	fun onSuccess(activePurchases: List<Purchase>)
	fun onFailure(error: Error)
}
 
private fun queryActivePurchasesForType(
   @BillingClient.SkuType type: String,
   listener: PurchasesResponseListener
) {
   onConnected {
       billingClient.queryPurchasesAsync(type, listener)
   }
}
 
fun queryActivePurchases(listener: OnQueryActivePurchasesListener) {
   queryActivePurchasesForType(
       BillingClient.SkuType.SUBS
   ) { billingResult, activeSubsList ->
       if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
           queryActivePurchasesForType(
               BillingClient.SkuType.INAPP
           ) { billingResult, nonConsumableProductsList ->
               if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                   listener.onSuccess(
                       activeSubsList.apply { addAll(nonConsumableProductsList) }
                   )
               } else {
                   listener.onFailure(
                       Error(billingResult.responseCode, billingResult.debugMessage)
                   )
               }
           }
       } else {
           listener.onFailure(
               Error(billingResult.responseCode, billingResult.debugMessage)
           )
       }
   }
}

В колбэке нам возвращается список объектов класса Purchase. Раньше, чтобы понять, к какому продукту относится покупка, нужно было вызвать ее метод getSku() и получить id продукта. Но в 2021 году на Google I/O были анонсированы покупки нескольких продуктов в рамках одной транзакции, поэтому в версии 4.0.0 getSku() сменился на getSkus() и возвращает список id продуктов, а еще добавился метод getQuantity(), который возвращает их количество. Правда, способ инициировать такую покупку я в документации не нашел: кажется, он пока не в релизе. Если что-то про это знаете, пишите в комментариях, ставьте лайки, жмите на колокольчик.

У метода queryActivePurchases() есть одна небольшая проблема — он берет покупки из локального кэша Play Services, и результат не всегда бывает актуален, особенно если покупка была совершена на другом устройстве. Лечится это несложным хаком, заодно добавим в BillingClientWrapper api для получения истории покупок: 

interface OnQueryPurchaseHistoryListener {
   fun onSuccess(purchaseHistoryList: List<PurchaseHistoryRecord>)
   fun onFailure(error: Error)
}
 
private fun queryPurchasesHistoryForType(
   @BillingClient.SkuType type: String,
   listener: PurchaseHistoryResponseListener
) {
   onConnected {
       billingClient.queryPurchaseHistoryAsync(type, listener)
   }
}
 
fun queryPurchaseHistory(listener: OnQueryPurchaseHistoryListener) {
   queryPurchasesHistoryForType(
       BillingClient.SkuType.SUBS
   ) { billingResult, subsHistoryList ->
       if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
           queryPurchasesHistoryForType(
               BillingClient.SkuType.INAPP
           ) { billingResult, inappHistoryList ->
               if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                   listener.onSuccess(
                       subsHistoryList?.apply { addAll(inappHistoryList.orEmpty()) }.orEmpty()
                   )
               } else {
                   listener.onFailure(
                       Error(billingResult.responseCode, billingResult.debugMessage)
                   )
               }
           }
       } else {
           listener.onFailure(
               Error(billingResult.responseCode, billingResult.debugMessage)
           )
       }
   }
}

Здесь колбэк возвращает список с самой последней покупкой каждого продукта, который пользователь когда-либо приобретал, а заодно синхронизирует локальный кэш активных покупок с актуальным состоянием. Также обратите внимание, что покупка здесь представлена не классом Purchase, а классом PurchaseHistoryRecord.

Теперь мы можем объявить метод получения только что синхронизированных активных покупок:

fun querySyncedActivePurchases(listener: OnQueryActivePurchasesListener) {
   queryPurchaseHistory(object : OnQueryPurchaseHistoryListener {
       override fun onSuccess(purchaseHistoryList: List<PurchaseHistoryRecord>) {
           queryActivePurchases(listener)
       }

       override fun onFailure(error: Error) {
           listener.onFailure(error)
       }
   })
}

Мы не просто так начали с получения покупок – следующая тема тесно связана с ними. Поговорим о замене подписок.

Смена подписок

В базовом случае, если купить обе подписки тем способом, который мы описали в предыдущей статье, они обе будут активны. Это не всегда ожидаемое поведение, поэтому, например, в App Store, как и в случае с consumable/non-consumable, это решается на уровне App Store Connect с помощью групп подписок. А с продуктами из Play Market, как и в случае с consumable/non-consumable, эту логику нужно делать на клиенте. 

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

fun changeSubscription(
   activity: Activity,
   newSub: SkuDetails,
   updateParams: BillingFlowParams.SubscriptionUpdateParams
) {
   onConnected {
       activity.runOnUiThread {
           billingClient.launchBillingFlow(
               activity,
               BillingFlowParams.newBuilder().setSkuDetails(newSub)
                   .setSubscriptionUpdateParams(updateParams).build()
           )
       }
   }
}

От обычной покупки он отличается только параметром SubscriptionUpdateParams. В классе SubscriptionUpdateParams.Builder нас интересуют 2 метода:

  1. setOldSkuPurchaseToken(String purchaseToken) — метод, в который мы передаем purchaseToken той активной покупки, где purchase.skus.first() равен id подписки, которую мы хотим заменить (мы получали список активных покупок в том числе и для этого).

  2. setReplaceSkusProrationMode(int replaceSkusProrationMode) — метод, в который нужно передать одну из констант BillingFlowParams.ProrationMode. На них остановимся поподробнее.

Каждая из констант говорит о том, когда подписка должна будет фактически смениться, а также какие финансовые транзакции должны будут произойти и в какой день. Рассмотрим на примерах. 

В нашем случае есть 2 подписки: помесячная за $9.99 и годовая за $49.99. Для удобства расчетов округлим их до $10 и $50 соответственно. Представим, что 1 сентября пользователь купил помесячную подписку, а 15-го решил сменить ее на годовую. Какие есть варианты:

ProrationMode.IMMEDIATE_WITH_TIME_PRORATION:

Подписка меняется немедленно. Так как прошло всего полмесяца, остались неиспользованные $5 от помесячной подписки, то есть 1/10 стоимости годовой – их хватает на 36 дней. Таким образом, $50 за годовую подписку пользователь заплатит 22 октября, следующее продление будет 22.10.2022 и т.д.

ProrationMode. IMMEDIATE_AND_CHARGE_PRORATED_PRICE:

Этот режим не подходит для нашего соотношения цен, потому что для него стоимость новой подписки за единицу времени должна превышать стоимость старой подписки за ту же единицу времени (у нас получается $50/г. ~ $4.17/мес. < $10/мес.). Поэтому пусть для этого кейса годовая подписка стоит $144.

Подписка меняется немедленно. Так же, как и в предыдущем кейсе, остаются 5 долларов и полмесяца до конца периода старой подписки, если б она не отменилась. Но для новой подписки полмесяца стоят $144/12/2=$6, поэтому с пользователя сразу снимают 1 доллар, и суммарные 6 долларов идут уже в счет новой подписки. 1 октября с пользователя снимут $144, следующее продление будет 01.10.2022 и т.д. 

ProrationMode.IMMEDIATE_WITHOUT_PRORATION:

Подписка меняется немедленно и без доплат — то есть, оставшиеся полмесяца будут уже с новой подпиской, но еще по старой цене. 1 октября пользователь заплатит $50, следующее продление будет 01.10.2022 и т.д.

ProrationMode.IMMEDIATE_AND_CHARGE_FULL_PRICE:

Подписка меняется немедленно, сразу же списывается $50. Так как оставшиеся $5 хватает на 36 дней годовой подписки, следующее продление будет через 1 год и 36 дней. 

ProrationMode.DEFERRED:

В предыдущей статье при описании колбэка onPurchasesUpdated(billingResult: BillingResult, purchaseList: MutableList<Purchase>?) был клиффхэнгер, что даже при успехе purchaseList может быть null – это тот самый кейс.

Помесячная подписка сменится на годовую только 1 октября, тогда же с пользователя снимут $50. Но колбэк onPurchaseUpdated(billingResult: BillingResult, purchaseList: MutableList<Purchase>?) вызовется уже сейчас, только в параметре purchaseList придет null.

В этом кейсе Google настоятельно рекомендует делать acknowledge новой подписки на бэке при получении нотификации SUBSCRIPTION_RENEWED, которая придет только 1 октября, потому что, как мы помним, если этого не сделать, подписка отменится через 3 дня, и если пользователь в начале октября не будет заходить в приложение, выполнить acknowledge на клиенте нет шансов.

Впрочем, всё, что связано с бэком, расскажем уже в пятой статье.

Про Adapty

А пока предлагаю вам подписаться на наш блог на Habr, чтобы не пропустить следующие материалы. Мы пишем про покупки в мобильных приложениях: от технической реализации до метрик подписок и монетизации приложений; а также записываем подкаст, где общаемся с интересными гостями на тему приложений.

И ещё советую познакомиться с Adapty SDK, с которым работа с подписками становится проще не только технически:

  • Встроенная аналитика позволяет легко понимать главные метрики приложения.

  • Когортный анализ показывает, сходится ли экономика.

  • А/Б тесты пейволлов делаются налету и помогают увеличивать конверсию в платных пользователей.

  • Интеграции с внешними системами позволяют отправлять транзакции в сервисы атрибуции и продуктовой аналитики.

  • Промо-кампании уменьшают отток аудитории.

  • Open source SDK позволяет интегрировать подписки в приложение за несколько часов.

  • Серверная валидация и API для работы с другими платформами упрощает работу с покупками.

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

Комментарии (0)