Irbisa · Шпаргалка
Встроенные покупки и подписки
StoreKit 2, Play Billing, права доступа, серверная валидация, восстановление, офферы, тестирование
Приложение продаёт месячную подписку, физическую толстовку и запись в барбершоп: что из этого обязано проходить через биллинг стора?
Пройдитесь по разовой покупке через Play Billing Library: какие вызовы происходят и в каком порядке?
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+…Как устроена подписка в Google Play (subscription, base plan, offer) и что такое
offerToken?Пользователь купил 500 монет, вечное отключение рекламы и месячную подписку: что каждая из этих трёх покупок обязывает делать клиент по-разному?
// 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…В каких состояниях бывает покупка в Play Billing и почему её нужно подтвердить (acknowledge) за три дня?
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…Пейволл показывает цены через раз, а покупки теряются. Что не так с этим кодом?
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 } }}Эта кнопка включает премиум, когда
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) } }}Приложение решает, что разблокировать, по флагу
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,…Клиент передаёт на бэкенд идентификатор покупки: что сервер с ним делает и почему проверить подпись на устройстве недостаточно?
// 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(…Продления, возвраты и отмены происходят, пока приложение закрыто: как бэкенд о них узнаёт и как обработчик переживает дубликаты?
// 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…Пользователь переустановил приложение, нажал «Восстановить», а вечная разблокировка не вернулась, хотя Play считает, что она куплена. Где искать?
suspend fun restore(): RestoreOutcome { val tokens = buildList { for (type in listOf(ProductType.INAPP, ProductType.SUBS)) { val result = billing.queryPurchasesAsync( QueryPurchasesParams.newBuilder().setProductType(type).build() )…Пользователю вернули деньги через Google Play. Как об этом узнаёт ваш бэкенд и что вы забираете обратно?
Пользователь заплатил через стор, затем входит в другой аккаунт вашего приложения и ждёт, что подписка приедет туда же. Кому достанется право доступа и как это обеспечить?
fun buy(activity: Activity, details: ProductDetails, offerToken: String, userId: String) { val params = BillingFlowParams.newBuilder() .setProductDetailsParamsList(listOf( BillingFlowParams.ProductDetailsParams.newBuilder() .setProductDetails(details) .setOfferToken(offerToken)…На пейволле написано «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)}Как перевести подписчика Play с месячного плана на годовой и что происходит со старой покупкой?
fun changePlan( activity: Activity, yearly: ProductDetails, offerToken: String, current: Purchase,) {…У подписчика посреди цикла истекает карта. Через какие состояния он пройдёт до потери доступа и что приложение показывает в каждом?
// 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…Пользователь присылает чек: деньги списаны, приложение по-прежнему показывает пейволл, перезапуск ничего не изменил. Как он получит свой контент?
Найдите баг: на экране оплаты $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", )Вы хотите поднять цену подписки на 30%. Что за вас сделает Google Play и когда текущий подписчик обязан согласиться?
Пользователь оформил подписку на iPhone, а потом открыл ваше Android-приложение и сайт. Где на самом деле живёт ответ «подписчик ли он»?
// 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)…Найдите баг: при нестабильной мобильной сети с некоторых покупателей за один заказ списывают деньги дважды.
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)}Как протестировать продления, возврат и отклонённую карту в Google Play, не потратив денег, и чего такое тестирование никогда не поймает?
Продукт хочет A/B-тест трёх цен на пейволле и еженедельный отчёт по результатам. Что для этого должно появиться в приложении и на сервере?
// 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)…