Покупки и подписки в приложении: StoreKit, Play Billing, права и восстановление
Трек прошёл путь от устройства платформ до релиза: приложение спроектировано, данные переживают оффлайн, фон не съедает батарею, сборка подписана и раскатывается ступенями, а работа с датчиками и медиа не разряжает телефон за час. Остался вопрос, который в реальном продукте возникает следующим и почти всегда застаёт команду врасплох: как приложение берёт деньги.
Кажется, что это задача из категории «подключить SDK за спринт». На практике это самая специфическая часть мобильной инженерии после жизненного цикла процесса, и вот почему. Платёж проходит через кассу, которой вы не владеете. Деньги идут мимо вас и приходят через месяц агрегированным отчётом. Пользователь может отменить подписку в системных настройках — приложение об этом не узнает. Может вернуть деньги через магазин, оставшись с открытой платной функцией. Может купить с одного аккаунта, а войти в приложение с другого. Может оплатить наличными в терминале, и покупка сутки провисит в состоянии «ожидает». А сверху лежит слой правил ревью, который отказывает не за баг, а за отсутствующую кнопку на экране оплаты.
Отсюда тезис статьи, из которого выводится вся архитектура: покупка и право на функциональность — две разные сущности, живущие в разных системах. Покупка — событие в магазине; вы её не храните и не контролируете. Право (entitlement) — состояние вашего продукта: «этому человеку сейчас положено вот это». Продукты, где право хранится как булев флаг рядом с чеком, ломаются на первом же возврате, первой смене устройства и первом входе с другого аккаунта.
Статья опирается на релиз и сторы (там разобрано ревью и раскатка), на безопасность (клиент — код на устройстве противника) и на данные и оффлайн (право тоже приходится кэшировать). Коммерческая сторона — комиссии, налоги, пропорциональные пересчёты, отчётность — разобрана в треке поставки софта: магазины приложений и биллинг. Здесь — инженерия клиента и границы между клиентом, площадкой и вашим сервером.
Два контура: где деньги и где право
Главное, что стоит вынести из этой схемы: вы не платёжный сервис. Вы не видите номер карты, не обрабатываете отказ банка, не делаете возврат и не отвечаете за налог — всё это берёт на себя площадка как продавец записи, и именно за это (а не за хостинг файла) удерживает комиссию. Ваша половина работы — превратить факт покупки в право и удерживать это право в актуальном состоянии всё время жизни подписки, включая моменты, когда приложение не запущено.
Из разделения контуров следует правило, которое экономит месяцы: интеграция с кассой прячется за интерфейс вашего домена. Экран не знает слов StoreKit и BillingClient, он знает «показать тарифы» и «текущее право». Когда завтра добавится оплата на сайте, промокод от маркетинга или корпоративная лицензия — появится ещё одна реализация того же интерфейса, а не второй пейволл. Это тот же принцип границ, что и в архитектуре.
Типы продуктов: слово «покупка» означает четыре разные вещи
| Тип | iOS (StoreKit) | Android (Play Billing) | Семантика | Что обязательно в коде |
|---|---|---|---|---|
| Расходуемый | consumable | INAPP + consumeAsync |
монеты, жизни, разовый кредит | пометить израсходованным, иначе не купят второй раз |
| Нерасходуемый | non-consumable | INAPP без consume |
«купить навсегда»: разблокировка функции | восстановление обязательно |
| Автопродляемая подписка | auto-renewable | SUBS + base plan |
основной способ монетизации | обработка всего жизненного цикла |
| Подписка без автопродления | non-renewing | INAPP со своим сроком |
сезонный доступ, курс на месяц | срок считаете вы сами |
Различие не косметическое: от типа зависит, кто помнит о покупке. Нерасходуемые покупки и подписки магазин помнит вечно и отдаёт по запросу восстановления; расходуемые — не помнит вообще. Если вы продаёте монеты и не начислили их на сервер, а приложение переустановили, монеты исчезли безвозвратно, и это будет тикет в поддержку с полным правом пользователя на возмущение.
Отдельно стоит отметить модель подписок Play, которая сбивает с толку пришедших из iOS. С Billing Library 5 подписка описывается тремя уровнями: продукт (premium) → базовый план (monthly, annual с ценой и периодом) → предложение (freetrial, winback — модификатор цены для части аудитории). У Apple плоская модель: продукт с ценой и периодом, объединённый с другими в группу подписок (subscription group), внутри которой пользователь может держать только один активный продукт, а переход между ними — это апгрейд/даунгрейд, а не вторая подписка. Группа у Apple и продукт с базовыми планами у Google решают одну задачу разными средствами; проектируя каталог, задайте себе вопрос «что здесь взаимоисключающее» и отразите это структурой, а не проверками в коде.
Каноническая последовательность покупки
Три места в этой диаграмме, которые ломают больше всего продуктов.
Слушатель транзакций поднимается при старте, а не на экране пейволла. Покупка может завершиться тогда, когда ваш экран уже уничтожен: пользователь ушёл в банковское приложение подтверждать операцию, система убила процесс (см. жизненный цикл), человек вернулся — а транзакция ждёт. Если слушатель живёт в модели экрана, покупка не будет обработана никогда. Она не потеряется в магазине, но зависнет в неподтверждённом состоянии, и площадка через некоторое время вернёт деньги.
Состояние «ожидает» — не ошибка и не успех. Оплата наличными через терминал в Play, режим Ask to Buy у семейных аккаунтов Apple, банковское подтверждение по SCA в Индии и Европе — всё это даёт покупку, которая физически ещё не оплачена. Выдать право сразу — подарить продукт; показать ошибку — потерять человека, который уже идёт платить. Правильный ответ: отдельное состояние экрана «оплата обрабатывается» и ожидание уведомления.
Подтверждение — обязательный шаг, а не вежливость. На Android покупка без acknowledgePurchase в течение трёх суток автоматически возвращается пользователю. На iOS неподтверждённая транзакция бесконечно приходит в слушатель при каждом запуске. Оба поведения — защита пользователя от продавца, который взял деньги и не выдал товар, и оба стоят вам выручки, если подтверждение сделано «когда-нибудь потом», а не сразу после записи права.
StoreKit 2: код, который переживает продакшн
StoreKit 2 (iOS 15+) убрал главную боль первой версии — ручную работу с очередью платежей и разбор бинарного чека. Транзакция приходит уже в виде JWS, подписанного Apple, а библиотека проверяет подпись сама.
import StoreKit
/// Слой покупок: знает про StoreKit, не знает про экраны.
actor Purchases {
private let backend: EntitlementSyncing
private var updatesTask: Task<Void, Never>?
/// Вызывается из инициализации приложения, а не из пейволла.
func start() {
updatesTask = Task.detached(priority: .background) { [weak self] in
// Сюда приходят: покупки, завершившиеся вне приложения; продления;
// возвраты; покупки, начатые в App Store (promoted in-app purchases).
for await update in Transaction.updates {
await self?.handle(update)
}
}
Task { await drainUnfinished() } // хвост с прошлого запуска
}
/// Незавершённые транзакции переживают перезапуск и убийство процесса.
private func drainUnfinished() async {
for await update in Transaction.unfinished {
await handle(update)
}
}
func purchase(_ product: Product, userId: String) async throws -> PurchaseOutcome {
// appAccountToken — единственный способ связать чек с вашим аккаунтом.
// Задать его можно ТОЛЬКО в момент покупки; задним числом связи не будет.
let token = UUID(uuidString: userId) ?? UUID()
let result = try await product.purchase(options: [.appAccountToken(token)])
switch result {
case .success(let verification):
return await handle(verification)
case .pending:
return .pending // Ask to Buy, SCA: право НЕ выдаём
case .userCancelled:
return .cancelled // не ошибка: не логируем как сбой
@unknown default:
return .cancelled
}
}
@discardableResult
private func handle(_ result: VerificationResult<Transaction>) async -> PurchaseOutcome {
// Проверка подписи Apple на устройстве — необходимое, но НЕ достаточное условие.
guard case .verified(let tx) = result else { return .failed(.unverified) }
do {
// Источник истины — ваш сервер. Клиент лишь курьер подписанного представления.
try await backend.grant(signedTransaction: result.jwsRepresentation)
await tx.finish() // только после того, как право записано
return .granted
} catch {
// НЕ вызываем finish(): транзакция вернётся в Transaction.updates
// при следующем запуске, и мы попробуем ещё раз.
return .failed(.backendUnavailable)
}
}
/// Текущие права по данным устройства — для быстрой отрисовки UI и оффлайна.
func localEntitlements() async -> Set<String> {
var ids = Set<String>()
for await result in Transaction.currentEntitlements {
guard case .verified(let tx) = result else { continue }
if tx.revocationDate != nil { continue } // возврат: право снято
if let exp = tx.expirationDate, exp < .now { continue }
ids.insert(tx.productID)
}
return ids
}
}
Что здесь неочевидно и что стоит проверить в своём коде.
Transaction.currentEntitlements— это мнение устройства, а не истина. Оно отражает покупки текущего Apple ID на этом устройстве. Оно ничего не знает про вашу подписку, купленную на Android или на сайте, и не выживает при смене платформы. Использовать его как единственный источник — значит согласиться, что подписка привязана к аккаунту магазина, а не к вашему.ownershipTypeотличает собственную покупку от полученной по семейному доступу. Если ваш продукт даёт персональные данные (облако, история), семейный доступ придётся ограничивать явно — и обязательно продумать, что показывать члену семьи, когда владелец подписку отменил.- Сообщения StoreKit. С iOS 16 система шлёт приложению сообщения — согласие на повышение цены, проблема с оплатой, сообщение из App Store Connect. Их можно и нужно отложить, чтобы системный лист не выпрыгнул поверх онбординга или платёжного экрана; но отложить — не значит проигнорировать: неполученное согласие на повышение цены заканчивается автоматической отменой подписки в дату повышения.
- Возврат внутри приложения.
beginRefundRequest(in:)показывает системный лист запроса возврата. Многие продукты сознательно ставят эту кнопку в поддержку: она дешевле, чем однозвёздочный отзыв «не смог вернуть деньги».
Play Billing: то же самое, но с явным соединением
Google Play Billing устроен иначе: клиент библиотеки — это подключение к сервису Play Store, которое может отвалиться (обновление Play Store, нехватка памяти, перезапуск сервиса), и его приходится восстанавливать.
class PlayPurchases(
context: Context,
private val backend: EntitlementSyncing,
private val scope: CoroutineScope,
) {
private val listener = PurchasesUpdatedListener { result, purchases ->
when (result.responseCode) {
BillingClient.BillingResponseCode.OK ->
purchases?.forEach { scope.launch { handle(it) } }
BillingClient.BillingResponseCode.USER_CANCELED -> Unit // не ошибка
else -> analytics.billingFailure(result.responseCode) // метрика, а не крэш
}
}
private val client = BillingClient.newBuilder(context)
.setListener(listener)
// Ожидающие покупки надо включить ЯВНО, иначе оплата наличными недоступна
.enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build())
.build()
/** Соединение поднимается при старте приложения и восстанавливается с backoff. */
fun connect(attempt: Int = 0) {
client.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(result: BillingResult) {
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
scope.launch { reconcile() } // главный шаг, см. ниже
} else retry(attempt)
}
override fun onBillingServiceDisconnected() = retry(attempt)
})
}
private fun retry(attempt: Int) {
val delayMs = minOf(1_000L shl attempt, 60_000L) // 1с, 2с, 4с … потолок минута
scope.launch { delay(delayMs); connect(attempt + 1) }
}
/**
* Сверка при каждом запуске и возврате из фона.
* Без неё теряются: покупки, завершённые вне приложения, покупки, начатые
* на другом устройстве, и покупки, пережившие смерть процесса.
*/
suspend fun reconcile() {
for (type in listOf(BillingClient.ProductType.SUBS, BillingClient.ProductType.INAPP)) {
val params = QueryPurchasesParams.newBuilder().setProductType(type).build()
client.queryPurchasesAsync(params) { _, purchases ->
scope.launch { purchases.forEach { handle(it) } }
}
}
}
private suspend fun handle(purchase: Purchase) {
when (purchase.purchaseState) {
Purchase.PurchaseState.PENDING -> return // наличные: ждём, права не даём
Purchase.PurchaseState.PURCHASED -> Unit
else -> return
}
// Токен проверяет сервер через Google Play Developer API.
val granted = backend.grant(purchase.purchaseToken, purchase.products.first())
if (!granted) return // повторим при следующей сверке
if (!purchase.isAcknowledged) {
// Три дня без подтверждения = автоматический возврат денег пользователю.
client.acknowledgePurchase(
AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchase.purchaseToken).build()
) { /* ошибку логируем: повторим на следующей сверке */ }
}
}
fun launch(activity: Activity, details: ProductDetails, offerToken: String, userId: String) {
val product = BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(details)
.setOfferToken(offerToken) // для SUBS обязателен: план + предложение
.build()
val params = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(listOf(product))
.setObfuscatedAccountId(userId.sha256()) // связь чека с вашим аккаунтом
.build()
client.launchBillingFlow(activity, params) // требует Activity: не вызвать из ViewModel
}
}
Практические отличия от iOS, которые надо держать в голове.
queryPurchasesAsyncпри каждом старте — обязательный шаг, а не оптимизация. СлушательPurchasesUpdatedListenerработает только пока процесс жив. Всё, что произошло без него, видно только через явный запрос.launchBillingFlowтребует живуюActivity. Это протекание платформы в архитектуру: слой покупок вынужденно получает ссылку на текущий экран. Спрятать это можно через тонкий адаптер в UI-слое, но нельзя сделать вид, что проблемы нет.- Версии библиотеки — не ваш выбор. Play ежегодно поднимает минимально допустимую версию Billing Library для новых сборок: устаревшая зависимость однажды просто перестанет принимать обновления. Это ровно та же дисциплина, что и с
targetSdk(см. релиз), и она обязана быть в календаре команды. - Обфусцированный идентификатор аккаунта не должен быть личными данными. В поле уезжает хеш или внутренний идентификатор, а не e-mail: это поле видно в консоли и в отчётах.
Право как состояние: где хранить истину
| Модель | Как работает | Плюсы | Чем платите |
|---|---|---|---|
| Только устройство | право читается из StoreKit / Play при старте | нет бэкенда, быстро сделать | нет кроссплатформы, нет поддержки, легко подделать, нет истории |
| Только сервер | сервер держит право, клиент спрашивает | одна истина, кроссплатформа, поддержка видит всё | оффлайн ломает продукт, если не кэшировать |
| Сервер + подписанный кэш | сервер — истина, клиент кэширует ответ с TTL | работает оффлайн, честно на длинной дистанции | нужен и бэкенд, и аккуратный кэш |
Для продукта дороже «разблокировать тему за 99 рублей» рабочая модель ровно одна — третья. Модель данных на сервере минимальна и не должна расти:
Две детали этой схемы делают всю разницу. Первая — право отделено от транзакции: продлений за два года будет двадцать четыре, а право одно, и UI спрашивает именно про него. Вторая — NOTIFICATION_LOG с processed_at: уведомления площадок приходят повторно, не по порядку и иногда с задержкой в часы, поэтому обработка обязана быть идемпотентной, а необработанные уведомления — видимыми. Общая механика — в статье про идемпотентность и гарантии доставки.
Серверная валидация и уведомления площадок
Первое правило: проверять чек на клиенте — значит проверять того, кого проверяешь. Всё, что работает на устройстве, доступно владельцу устройства: подмена ответа, хук на метод проверки, подставной локальный сервер. Как это делается практически, разобрано в безопасности; вывод для монетизации простой — решение о выдаче права принимает сервер, а клиент лишь сообщает подписанное представление покупки.
| Задача | App Store | Google Play |
|---|---|---|
| Проверить покупку | App Store Server API: статусы подписок и транзакции по transactionId |
Google Play Developer API: purchases.subscriptionsv2.get, purchases.products.get |
| Аутентификация сервера | JWT (ES256), подписанный ключом App Store Connect API | сервисный аккаунт Google Cloud, OAuth 2.0 |
| Формат данных | JWS, подписанный Apple, проверяется по цепочке сертификатов | JSON от API, доверие даёт TLS и авторизация |
| События жизненного цикла | App Store Server Notifications V2 (HTTPS-вебхук, JWS) | Real-time developer notifications через Cloud Pub/Sub |
| Возвраты | REFUND, REVOKE, запрос CONSUMPTION_REQUEST |
purchases.voidedpurchases.list + уведомление REVOKED |
| Сверка без событий | опрос статусов по originalTransactionId |
опрос по purchaseToken |
Устаревший путь, который до сих пор встречается в статьях: отправлять base64-чек на verifyReceipt. Apple объявила этот эндпоинт устаревшим и рекомендует App Store Server API; новый код на нём писать не нужно. Про подпись и проверку JWT-подобных структур — JWT и токены.
Второе правило, менее очевидное: уведомления — не гарантия, а оптимизация. Вебхук может не дойти, ваш сервис может лежать в момент доставки, Pub/Sub может задержать сообщение. Поэтому рядом с обработчиком уведомлений всегда стоит сверка: периодическая (по подпискам, у которых expires_at близко или уже в прошлом) и по требованию (клиент пришёл с чеком — проверили статус заново). Продукт, который живёт только на вебхуках, рано или поздно обнаруживает когорту пользователей с вечной бесплатной подпиской — тех, чьё уведомление об отмене потерялось.
/** Обработчик уведомления площадки: идемпотентный, безопасный к повторам и порядку. */
async function onStoreNotification(raw: unknown, platform: "app_store" | "play") {
const n = await verifySignature(raw, platform); // JWS Apple / подпись Pub/Sub
// 1. Дедупликация: одно и то же уведомление придёт ещё раз — это норма, а не сбой.
const fresh = await db.notifications.insertIfAbsent({
uuid: n.notificationUUID, type: n.type, payload: n.raw,
});
if (!fresh) return ok(); // уже обрабатывали
// 2. Не доверяем полезной нагрузке: перечитываем статус из API площадки.
// Уведомление говорит «что-то изменилось», API говорит «как теперь есть».
const state = await stores[platform].fetchState(n.originalTransactionId);
// 3. Защита от обгона: старое уведомление не должно откатить новое состояние.
const current = await db.entitlements.byExternalId(n.originalTransactionId);
if (current && current.stateVersion >= state.version) return ok();
await db.entitlements.upsert({
userId: await resolveUser(n, state), // appAccountToken / obfuscatedAccountId
tier: productToTier(state.productId),
expiresAt: state.expiresAt,
inGracePeriod: state.isInGracePeriod,
revokedAt: state.isRevoked ? new Date() : null,
stateVersion: state.version,
});
await db.notifications.markProcessed(n.notificationUUID);
return ok();
}
Третье правило: отвечайте на запрос о потреблении. Apple присылает CONSUMPTION_REQUEST, когда пользователь запросил возврат за расходуемый товар, и ждёт от вас данных о том, было ли содержимое израсходовано. Ответ влияет на решение о возврате и присылается в узком временном окне. Для продуктов с внутренней валютой это прямые деньги.
Жизненный цикл подписки: состояний больше, чем кажется
Ключевой инженерный вопрос по этой схеме — в каких состояниях право открыто. Ответ не интуитивен и его надо принять осознанно, а не вывести из кода:
| Состояние | Право открыто | Почему |
|---|---|---|
| Trial | да | пробный период — это доступ, иначе он бессмысленен |
| Active | да | — |
| CancelPending | да | человек оплатил период до конца; отключить сейчас — прямой путь к спору о возврате |
| BillingRetry без грейса | нет | оплаты не было |
| Grace | да | смысл грейса именно в том, чтобы не наказывать за просроченную карту |
| Hold (Play) | нет | удержание может длиться до месяца |
| Paused (Play) | нет | пользователь сам попросил паузу |
| Refunded / Revoked | нет, немедленно | деньги вернули; продолжать давать доступ — подарок |
| Expired | нет | — |
Грейс-период и удержание включаются в консолях площадок и по умолчанию настроены не так, как многим кажется. Стоит зайти и посмотреть глазами: у Play грейс и account hold конфигурируются отдельно, у Apple грейс включается на уровне приложения. Экономика простая: включённый грейс-период возвращает часть подписчиков, чья карта отвалилась по техническим причинам, и стоит вам несколько дней бесплатного доступа. Расчёт этого компромисса — в юнит-экономике.
Восстановление покупок: обязательная кнопка и то, что за ней стоит
Кнопка «Восстановить покупки» — требование обеих площадок, и её отсутствие входит в топ причин отказа на ревью для приложений с нерасходуемыми покупками. Но за банальной кнопкой стоит вопрос, на который надо ответить архитектурно: к чему привязано право — к аккаунту магазина или к вашему аккаунту.
Узел CONFLICT — тот самый случай, который забывают и который потом занимает половину времени поддержки. Семья с одним Apple ID на iPad и двумя разными аккаунтами в вашем приложении; человек, продавший телефон; общий аккаунт магазина у пары. Правило, которое спасает: один originalTransactionId может быть привязан ровно к одному вашему пользователю, попытка привязать его ко второму — не тихая перезапись, а явный сценарий с понятным текстом. Тихая перезапись означает, что первый пользователь молча потеряет оплаченную подписку и напишет отзыв.
Практические детали восстановления:
- Восстановление — не то же самое, что синхронизация. На iOS
AppStore.sync()заставляет систему перезапросить транзакции и потребует ввода пароля Apple ID — это тяжёлая операция, уместная только по явному нажатию кнопки. Обычный запуск обходитсяTransaction.currentEntitlementsбез единого диалога. - На Android отдельного «восстановления» нет — есть
queryPurchasesAsync, который вы и так вызываете при каждом старте. Кнопку всё равно стоит показать: она снимает тревогу пользователя, который не понимает, почему подписка «пропала». - Расходуемые покупки не восстанавливаются в принципе. Единственная защита — начислять их на сервере, а не в локальную базу.
Оффлайн: право, которое нельзя проверить прямо сейчас
Оффлайн — не исключение, а нормальный режим (см. данные и оффлайн), и подписка обязана его переживать. Загруженный офлайн-плейлист, который не играет в метро, потому что сервер прав недоступен, — это отзыв на одну звезду с точной формулировкой претензии.
/**
* Кэш права: сервер подписывает ответ, клиент хранит его вместе с подписью.
* Подпись не защищает от владельца устройства (он всё равно может пропатчить
* приложение), но защищает от простой подмены файла и делает обход осознанным
* действием, а не «поправил JSON в песочнице».
*/
data class CachedEntitlement(
val tier: String,
val expiresAt: Instant?, // конец оплаченного периода
val checkedAt: Instant, // когда сервер это подтвердил
val signature: String, // подпись сервера над полезной нагрузкой
)
class EntitlementGate(
private val clock: Clock,
private val softWindow: Duration = Duration.ofDays(7), // сколько живём без связи
) {
fun decide(cache: CachedEntitlement?, online: Boolean, fresh: Entitlement?): Access = when {
online && fresh != null -> Access.from(fresh) // истина с сервера
cache == null -> Access.Denied("нет данных о подписке")
cache.expiresAt?.isBefore(clock.instant()) == true ->
Access.Denied("период оплаты закончился") // это мы знаем и оффлайн
Duration.between(cache.checkedAt, clock.instant()) <= softWindow ->
Access.Granted(warning = null) // обычный оффлайн
else -> Access.Granted(warning = "не удаётся проверить подписку") // мягкая деградация
}
}
Ключевое проектное решение здесь называется просто: fail open или fail closed. Открывать доступ при невозможности проверки — терять деньги на тех, кто держит телефон в самолётном режиме специально. Закрывать — наказывать честных пользователей за плохую связь. Разумный компромисс: льготное окно (несколько дней до недели) с мягким предупреждением, жёсткое закрытие после него, и всегда честное закрытие, если срок оплаченного периода истёк по локальным часам — это факт, для проверки которого сеть не нужна. И отдельно: никогда не открывайте право по часам устройства без верхней границы — перевод даты вперёд и назад давно известный приём.
Второе правило оффлайна: обновление права — фоновая работа, а не блокирующий запрос на старте. Тихий пуш от вашего сервера при смене статуса подписки плюс проверка при возврате из фона покрывают почти все случаи, а механика — та же, что в фоновой работе и пушах.
Пейволл: экран, который проверяет не только пользователь
Экран оплаты — единственный экран приложения, который читает ревьюер построчно. Отказы здесь дешевле предотвратить, чем оспорить.
Что обязано быть на экране подписки у обеих площадок:
- Название продукта и что именно даёт подписка — без обещаний, которых нет в приложении.
- Цена, период и явное указание автопродления. Формулировка «продлевается автоматически, пока не отменена» — не маркетинг, а требование.
- Условия пробного периода, если он есть: длительность и цена после.
- Ссылки на условия использования и политику приватности — прямо на экране, а не в глубине настроек.
- Кнопка восстановления покупок.
- Цена берётся из API площадки, а не из вашего кода.
displayPriceу StoreKit иformattedPriceу Play уже локализованы, в нужной валюте и с учётом местных налоговых правил. Хардкод «299 ₽» — это гарантированная ложь в половине стран и повод для отказа.
Отдельная зона риска — правила против увода из магазина (anti-steering): куда можно и нельзя вести пользователя, можно ли упоминать оплату на сайте, при каких условиях разрешены внешние ссылки. Эта область активно меняется под давлением регуляторов и судов в разных юрисдикциях, поэтому единственный разумный подход — проверять актуальный текст правил перед тем, как проектировать такую функциональность, и держать её за фича-флагом с региональным условием. Разбор экономики и правил площадок — в магазинах приложений.
С точки зрения UI пейволл подчиняется тем же правилам, что и любой мобильный экран (см. UI и навигацию): у него обязаны быть все пять состояний. Загрузка каталога, пустой каталог (продукты не подгрузились — это бывает чаще, чем кажется), ошибка, успех и состояние «уже подписан». Пейволл, который при недоступном каталоге показывает пустой экран с кнопкой «Купить», — типовой баг, живущий в проде месяцами, потому что на машине разработчика каталог всегда грузится.
Тестирование: как проверить то, за что платят настоящими деньгами
| Что проверяем | iOS | Android |
|---|---|---|
| Каталог, покупка, ошибки | StoreKit Configuration File локально, без сети и без сервера Apple | лицензионные тестеры + внутреннее тестирование |
| Автотесты покупок | SKTestSession в юнит- и UI-тестах |
подставной слой над BillingClient |
| Продление подписки | песочница с ускоренным временем | сжатые периоды для тестеров |
| Возврат, отмена, грейс | смоделировать в конфигурации или в песочнице | отмена в Play Store тестового аккаунта |
| Уведомления сервера | песочница шлёт нотификации на отдельный URL | Pub/Sub-топик стенда |
Локальная конфигурация StoreKit — самый недооценённый инструмент: она позволяет прогонять покупки в симуляторе, без интернета и без учётной записи, а заодно моделировать то, что в песочнице воспроизвести трудно, — прерывание покупки, отказ, ask-to-buy, возврат. В песочнице Apple подписки продлеваются ускоренно (месячная — за считанные минуты) и делают ограниченное число автопродлений, после чего останавливаются; закладывайте это в сценарий теста, иначе будете ждать «настоящего» продления бесконечно. У Play лицензионные тестеры получают аналогично сжатые периоды и не платят реальных денег.
Четыре сценария, которые почти никогда не тестируют и которые ломаются в проде чаще всего:
- Смерть процесса посреди покупки. Начать оплату, свернуть приложение, убить процесс (способы — в тестировании), вернуться. Право обязано появиться без единого действия пользователя.
- Покупка при недоступном бэкенде. Отключить свой сервер, купить. Деньги списаны, право выдать нельзя — приложение обязано сообщить об этом честно и выдать право само, когда сервер вернётся, не требуя от человека повторной покупки.
- Возврат. Оформить возврат в песочнице и убедиться, что право снимается, а приложение не падает, обнаружив исчезнувшую подписку.
- Два аккаунта на одном устройстве. Купить под пользователем A, выйти, войти под B. Ожидаемое поведение должно быть описано в спецификации, а не решаться кодом «как получилось».
Кроссплатформа и готовые сервисы
Слой покупок — редкий случай, когда кроссплатформенный код почти не даёт экономии: обе кассы всё равно нативные, различия в модели каталога никуда не деваются, а серверная часть у вас одна в любом случае. Что реально есть под рукой в кроссплатформенных стеках (подробнее о цене выбора — в кроссплатформе):
| Стек | Типовое решение | Что остаётся вам |
|---|---|---|
| Flutter | in_app_purchase (официальный плагин) |
серверная валидация, права, обработка уведомлений |
| React Native | react-native-iap и аналоги |
то же самое |
| Kotlin Multiplatform | нативные реализации за общим интерфейсом | естественно ложится на модель expect/actual |
| Любой | сервис управления подписками (RevenueCat и подобные) | пейволл, продукт, поддержка |
Решение «писать самим или взять сервис» разумно принимать по трём осям, а не по цене подписки на сервис. Сколько платформ и касс — одна платформа и одна цена, скорее всего, не окупят интеграцию; три платформы плюс веб-оплата почти всегда окупают. Есть ли у вас бэкенд и дежурство — уведомления площадок надо принимать круглосуточно, а не в рабочее время. Насколько сложен каталог — плоская подписка проще, чем тарифная сетка с апгрейдами и региональными ценами, где пропорциональные пересчёты становятся отдельным проектом (см. биллинг).
Осевой критерий: сервис берёт процент от оборота, а своя реализация — фиксированную стоимость разработки и поддержки. Пороговый оборот, при котором линии пересекаются, считается за час, и его стоит посчитать до, а не после.
Что мерить
Продуктовые метрики монетизации — конверсия, ARPU, отток, LTV — разобраны в метриках и в ценообразовании и росте. Здесь — инженерные метрики, которых нет в продуктовых дашбордах и которые сигналят о поломке раньше, чем упадёт выручка:
| Метрика | О чём говорит | Тревожный сигнал |
|---|---|---|
| Доля неподтверждённых покупок | покупка прошла, finish/acknowledge не случился |
больше нуля — это утечка денег |
| Доля отказов серверной валидации | подделка, баг интеграции или сбой API площадки | резкий рост после релиза |
| Лаг обработки уведомлений | вебхуки копятся или теряются | десятки минут и растёт |
| Расхождение «право активно» и «подписка активна» | сверка не работает | любое устойчивое расхождение |
| Ошибки инициализации кассы | Play Billing не подключился, каталог не загрузился | доля выше процента |
| Доля покупок без привязки к аккаунту | не передан appAccountToken |
больше нуля |
Практика, которую стоит завести с первого дня: ежедневная сверка выручки из консоли площадки с числом активных прав в вашей базе. Расхождение — это либо потерянные деньги, либо выданные задаром права, и обнаруживать его надо самим, а не по тикету из поддержки.
Типичные ошибки
- Слушатель транзакций поднимается на экране пейволла. Покупки, завершившиеся после смерти процесса или начатые из App Store, теряются; деньги возвращаются автоматически.
- Право хранится флагом в
UserDefaults/SharedPreferences. Переустановка обнуляет, возврат не снимает, кроссплатформа невозможна, обход тривиален. - Не передан
appAccountToken/obfuscatedAccountId. Задним числом связать покупку с пользователем нечем — только вручную через поддержку по идентификатору транзакции. - Валидация чека на клиенте. Экономия недели разработки, которая оплачивается бесплатными подписками у всех, кто умеет читать инструкции из интернета.
- Право открыто в состоянии «отменена, но период не истёк». Ровно наоборот: право должно быть открыто до конца оплаченного периода, иначе вы получаете и спор о возврате, и отзыв.
- Отсутствие обработки грейс-периода. Пользователь с временно отвалившейся картой мгновенно теряет доступ и уходит вместо того, чтобы обновить карту.
- Игнорирование состояния «ожидает». Оплата наличными и Ask to Buy трактуются как ошибка, человек видит «не удалось» и не возвращается.
- Цена захардкожена в приложении. Неверная валюта, неверная сумма, отказ на ревью.
- Нет кнопки восстановления. Отказ на ревью и поток тикетов «купил, но не работает».
- Продукт работает только на вебхуках, без периодической сверки. Растущая когорта пользователей с бессмертной бесплатной подпиской.
- Пейволл без состояния «каталог не загрузился». Кнопка «Купить», которая ничего не делает, живёт в проде месяцами.
- Тестирование только «счастливого пути» в песочнице. Возврат, смена аккаунта, оффлайн и смерть процесса не проверены до тех пор, пока не случились у пользователя.
Мини-итог
- Покупка и право — разные сущности: первая живёт в магазине, вторым управляете вы.
- Слушатель транзакций и сверка покупок — часть запуска приложения, а не экрана оплаты.
- Подтверждение покупки (
finish/acknowledge) обязательно и делается сразу после записи права; иначе площадка вернёт деньги сама. - Связь чека с вашим пользователем задаётся только в момент покупки —
appAccountTokenиobfuscatedAccountIdне добавляются задним числом. - Валидация — на сервере, серверным API площадки; клиент лишь передаёт подписанное представление.
- Уведомления площадок — оптимизация; страховкой служит периодическая сверка статусов.
- В состояниях «отменена до конца периода» и «грейс» право открыто; в «холде», «паузе» и после возврата — закрыто немедленно.
- Оффлайн решается подписанным кэшем с льготным окном, а решение fail open / fail closed принимается осознанно и записывается.
- Пейволл обязан содержать цену из API площадки, условия автопродления, ссылки на документы и кнопку восстановления — иначе это отказ на ревью.
- Тестируются не только покупки, но и возврат, смерть процесса, недоступный бэкенд и смена аккаунта.
Источники
- Apple. In-App Purchase и StoreKit — каталог, покупка, транзакции, сообщения, запрос возврата.
- Apple. App Store Server API и App Store Server Notifications V2 — серверная проверка и события жизненного цикла.
- Apple. Testing in-app purchases — локальная конфигурация StoreKit и
SKTestSession. - Android Developers. Google Play Billing Library — подключение, каталог, подтверждение покупок, ожидающие покупки.
- Android Developers. Real-time developer notifications и Google Play Developer API — уведомления и серверная проверка.
- Android Developers. Subscriptions: base plans and offers — модель каталога подписок Play.
- App Review Guidelines, раздел 3.1 — требования к покупкам и пейволлу.
- Google Play Payments Policy — что обязано идти через кассу площадки.
Что дальше
Этой статьёй трек «Мобильная разработка» закончен: от устройства платформ и жизненного цикла приложения — через архитектуру, оффлайн, фоновую работу, производительность, безопасность, тестирование, релиз и границу с физическим миром — до денег внутри приложения. Куда идти дальше, зависит от того, что вы почувствовали своим слабым местом.
- Слабое место — деньги и правила площадок. Поставка софта — модели доставки, лицензирование, биллинг и магазины приложений целиком.
- Слабое место — доставка и инфраструктура сборки. DevOps: CI/CD, облака и эксплуатация — конвейеры, стратегии релиза, наблюдаемость и дежурства.
- Слабое место — качество до релиза. Тестирование и, в частности, мобильное и совместимостное тестирование.
- Слабое место — граница с сервером. Распределённые системы — идемпотентность, доставка, согласованность: обработка уведомлений площадок — ровно эта задача.
- Слабое место — защита клиента и данных. Безопасность.
- Слабое место — решения о продукте. Продуктовый менеджмент — метрики, эксперименты, приоритизация и ценообразование.
И общая карта портала, если хочется выстроить дальнейший маршрут осознанно, — Роадмап: там видно, какие треки складываются в связные цепочки и в каком порядке их проходить.