Мобильная разработка Покупки и подписки в приложении: StoreKit, Play Billing, права и восстановление
0%

Покупки и подписки в приложении: StoreKit, Play Billing, права и восстановление

Покупки и подписки в приложении: 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 лицензионные тестеры получают аналогично сжатые периоды и не платят реальных денег.

Четыре сценария, которые почти никогда не тестируют и которые ломаются в проде чаще всего:

  1. Смерть процесса посреди покупки. Начать оплату, свернуть приложение, убить процесс (способы — в тестировании), вернуться. Право обязано появиться без единого действия пользователя.
  2. Покупка при недоступном бэкенде. Отключить свой сервер, купить. Деньги списаны, право выдать нельзя — приложение обязано сообщить об этом честно и выдать право само, когда сервер вернётся, не требуя от человека повторной покупки.
  3. Возврат. Оформить возврат в песочнице и убедиться, что право снимается, а приложение не падает, обнаружив исчезнувшую подписку.
  4. Два аккаунта на одном устройстве. Купить под пользователем 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 площадки, условия автопродления, ссылки на документы и кнопку восстановления — иначе это отказ на ревью.
  • Тестируются не только покупки, но и возврат, смерть процесса, недоступный бэкенд и смена аккаунта.

Источники

Что дальше

Этой статьёй трек «Мобильная разработка» закончен: от устройства платформ и жизненного цикла приложения — через архитектуру, оффлайн, фоновую работу, производительность, безопасность, тестирование, релиз и границу с физическим миром — до денег внутри приложения. Куда идти дальше, зависит от того, что вы почувствовали своим слабым местом.

И общая карта портала, если хочется выстроить дальнейший маршрут осознанно, — Роадмап: там видно, какие треки складываются в связные цепочки и в каком порядке их проходить.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов