Только месячная подписка. Всё цифровое, что потребляется внутри приложения, обязано идти через StoreKit или Play Billing; физические товары и услуги в реальном мире через биллинг стора проводить запрещено.
Биллинг стора: подписки, вечные разблокировки, игровая валюта, отключение рекламы: всё, что существует только в приложении. Комиссия 15–30%: Play берёт 15% с подписок и с первого $1 млн выручки разработчика за год, а Apple берёт 15% для малого бизнеса и с подписок после первого года
Ваш платёжный провайдер: физические товары, доставка, билеты, поездки, еда и услуги, которые оказывает человек; приложения, проводящие их через встроенный биллинг, отклоняют оба стора
Тоже мимо стора: пожертвования зарегистрированным НКО
Исключения меняются: решения судов в США и Digital Markets Act в ЕС в некоторых витринах разрешают ссылку на внешнюю оплату или альтернативный биллинг, поэтому держите это соответствие в серверном конфиге, а не в голове разработчика
AndroidMiddle
Встроенные покупки и подписки
StoreKit 2, Play Billing, права доступа, серверная валидация, восстановление, офферы, тестирование
Закрыто вопросов
0 / 23
- 0Повторить
Вопросы
Подключаете один BillingClient, получаете ProductDetails через queryProductDetailsAsync, открываете окно оплаты через launchBillingFlow, получаете результат в PurchasesUpdatedListener, затем проверяете токен на своём сервере и делаете acknowledge или consume.
Подключение: startConnection асинхронный: ничего не вызывайте, пока onBillingSetupFinished не вернёт OK. В Billing Library 8 появился enableAutoServiceReconnection(), и упавший сервис переподключается при следующем вызове
Запрос: цена берётся из ProductDetails (oneTimePurchaseOfferDetails.formattedPrice), а не из ваших строк; продукт должен быть активен в Play Console
Запуск: launchBillingFlow только открывает окно оплаты; его результат говорит, открылось ли окно, а не заплатил ли пользователь
Результат: покупка приходит в onPurchasesUpdated, возможно в состоянии PENDING; доступ выдаётся только для PURCHASED и только после проверки токена сервером
Завершение: acknowledge или consume в течение трёх дней, а на каждом возврате в приложение нужен проход queryPurchasesAsync за покупками, которые слушатель пропустил
Версия библиотеки: используйте актуальную мажорную версию (8 или новее); Play отклоняет обновления на старых мажорных версиях, а примеры эпохи SkuDetails уже не компилируются
Подписка представляет собой продукт, которым владеет пользователь, base plan задаёт её период оплаты, цену и тип продления, а offer означает пробный период или скидку, привязанные к одному base plan. Каждый вариант, доступный пользователю, приходит как SubscriptionOfferDetails со своим offerToken.
Subscription: один product id, например premium, к которому привязаны преимущества
Base plan: monthly или yearly, с автопродлением или предоплаченный; переход между base plan считается апгрейдом или даунгрейдом
Offer: бесплатный пробный период, вводная цена или скидка по вашим правилам, собранные из pricingPhases (например, 7 дней бесплатно, затем 3 месяца за полцены, затем полная цена); прежде чем показать offer, Play проверяет его условие доступности, например «никогда не было этой подписки»
**offerToken**: то, за что на самом деле списывает launchBillingFlow; у записи самого base plan offerId == null
Монеты списывайте (consume) только после того, как сервер их начислил, разблокировку спрашивайте у стора, а не храните, а состояние подписки читайте с вашего бэкенда.
Расходуемая покупка, монеты: вызывайте consumeAsync (Play) или finish() (StoreKit) только после начисления: если списали и упали, монеты пропали, а если не списали вовсе, пользователь не сможет купить этот набор снова
Баланс живёт на сервере: локальный счётчик после переустановки не восстановить
Нерасходуемая, разблокировка: этот факт навсегда принадлежит стору: Transaction.currentEntitlements на iOS и queryPurchasesAsync в Play, поэтому клиент спрашивает, а не помнит; в Play её нужно подтвердить (acknowledge) в течение трёх дней, иначе покупка вернётся
Автопродление, подписка: продления, отмены и возвраты происходят на серверах стора, пока приложение закрыто, так что закэшированная дата окончания уже под вопросом
Правило: клиент не хранит ничего, о чём можно спросить стор или ваш сервер
Purchase бывает PURCHASED, PENDING или UNSPECIFIED_STATE; подтверждённость хранится в отдельном флаге isAcknowledged. Покупку, которую не подтвердили и не списали (consume) в течение трёх дней после перехода в PURCHASED, Play автоматически возвращает и отзывает.
**PENDING: пользователь выбрал оплату наличными или медленный способ оплаты; деньги не двигались, поэтому доступ не выдаём и ничего не подтверждаем; трёхдневный отсчёт ещё не начался
PURCHASED: оплачено: проверьте токен на сервере, запишите право доступа, затем подтвердите
Как подтверждать**: consumeAsync для расходуемой покупки (он заодно подтверждает), acknowledgePurchase для нерасходуемой и для новой или заменённой подписки; обычные продления ничего не требуют. Сервер может сделать это сам через purchases.products.acknowledge или purchases.subscriptions.acknowledge
Зачем это правило: подтверждение означает «контент доставлен»; без него падение между списанием и выдачей оставило бы пользователя заплатившим ни за что
Частая ошибка: подтверждать в клиентском колбэке до того, как сервер выдал доступ: потерянный запрос оставит подтверждённую покупку без премиума
У Apple иначе: незавершённую транзакцию никто не возвращает; StoreKit просто доставляет её заново на каждом запуске, пока вы не вызовете finish()
startConnection асинхронный, поэтому запрос уходит до подключения к сервису и падает с SERVICE_DISCONNECTED. Вдобавок клиент живёт внутри одного экрана с пустым слушателем, и результатам покупок некуда прийти.
Дождитесь подключения: запрашивайте только после того, как onBillingSetupFinished вернул OK, или оберните подключение в suspend-функцию, которую ждёт репозиторий
Готовьтесь к разрывам: сервис Play может отвалиться в любой момент; в Billing Library 8 есть enableAutoServiceReconnection(), в старых версиях переподключайтесь с backoff из onBillingServiceDisconnected
Один клиент на приложение: создавайте его в репозитории уровня приложения, например Hilt @Singleton, а не в каждой ViewModel: покупка может завершиться, когда пейволла уже нет
Пустой слушатель: { _, _ -> } выбрасывает каждый результат покупки, а ведь именно туда покупки и приходят
Закрывайте то, что ограничили: клиенту с жизненным циклом короче приложения нужен endConnection() при уничтожении, иначе утечёт привязка к сервису
OK от launchBillingFlow значит лишь, что окно оплаты открылось, поэтому премиум получит любой, кто его открыл и закрыл. Кроме того, код пропускает все покупки, которые не возвращаются через эту кнопку: отложенные оплаты, процесс, убитый посреди покупки, и покупки, начатые вне приложения.
Не тот сигнал: результат приходит позже в PurchasesUpdatedListener, и продажей считается только проверенная сервером покупка в состоянии PURCHASED
Pending: наличные и медленные способы оплаты возвращают PENDING и завершаются через часы, когда этого экрана давно нет
Убитый процесс: приложение умерло между окном оплаты и колбэком; покупка есть, а выдача доступа так и не выполнилась
Мимо кнопки: промокоды, активированные в Play Store, а на iOS также продвигаемые покупки и одобрения Ask to Buy
Настоящая точка входа: слушатель уровня приложения плюс проход queryPurchasesAsync при каждом возврате в приложение; на iOS для этого служит Transaction.updates, запущенный на старте. Пейволл начинает покупку, а завершает её слушатель
Нет проверки: локальный флаг без проверки на сервере возвращает проблему is_premium: теряется при переустановке и не отзывается при возврате денег
Булев флаг не вмещает четыре вещи, из которых состоит право доступа (чьё оно, до какого момента, с какой платформы и в каком состоянии), поэтому оно ломается у платящих задолго до попыток что-то подделать.
Переустановка или новый телефон: флага нет, и оплативший подписчик смотрит на ваш пейволл
Отмена, истечение или возврат: стор ничего приложению не сообщает, флаг остаётся true, и отвалившийся пользователь бесплатно сидит в премиуме
Grace period: правда звучит как «оплачено, но карта не прошла»; булево значение не умеет попросить починить карту, и подписка тихо умирает
Что читать вместо этого: одну запись о праве доступа с бэкенда: productId, status, expiresAt, willRenew и source, обновляемую на запуске, на возврате в foreground и после любой покупки
Офлайн-политика: уважать закэшированное активное право ограниченное время и никогда не выдавать новое право офлайн
Сервер меняет идентификатор на актуальный взгляд стора на эту покупку. Подпись доказывает лишь то, что данные пришли от стора в момент выдачи, но не то, что покупка ещё действует и что она принадлежит именно этому пользователю.
Apple: сервер зовёт App Store Server API по originalTransactionId, авторизуясь ES256-токеном, и проверяет подписанные транзакции (JWS) из ответа по корневым сертификатам Apple
Google: сервер зовёт purchases.subscriptionsv2.get под сервисным аккаунтом и читает subscriptionState и дату окончания
Ловушка Play: при апгрейде или переоформлении Play выдаёт новый токен с linkedPurchaseToken на старый; если это проигнорировать, один платящий превратится в две живые подписки
Подпись даёт лишь снимок: она ничего не знает о возврате или чарджбэке после подписания, а проверка на устройстве выполняется на оборудовании пользователя, и её можно вырезать из сборки
Реплей: одна утёкшая транзакция, отправленная со ста устройств, даст сто выдач, если у идентификатора покупки нет уникального индекса
Оба стора шлют события жизненного цикла на зарегистрированный вами эндпоинт: App Store Server Notifications V2 и Real-time Developer Notifications в Play через Pub/Sub. Уведомление стоит считать поводом перезапросить состояние, а не самим состоянием.
Apple: один HTTPS-адрес на окружение; тело это подписанный JWS с типами DID_RENEW, DID_FAIL_TO_RENEW, EXPIRED, REFUND. Отвечайте 200 быстро, работу делайте после
Google: RTDN кладёт в ваш топик Pub/Sub токен покупки и тип, но намеренно не состояние; доставка at-least-once и без порядка, поэтому дубликаты считаются обычным трафиком
Дедуплицируйте по идентификатору стора: notificationUUID или messageId в таблице обработанных с уникальным индексом, в одной транзакции с правом доступа
Никогда не применяйте дельту: пишите абсолютное состояние, которое только что получили, под защитой монотонного поля, чтобы опоздавшее событие не затёрло свежее
Сверяйтесь: заведите джобу по подпискам, которые скоро истекают: потерянное уведомление это либо бесплатный премиум, либо отрезанный платящий клиент
Проверьте, под каким Google-аккаунтом работает Play Store и что это был за продукт: восстановление читает то, чем владеет аккаунт стора, а не то, за что платил ваш аккаунт в приложении. На Android нет API восстановления, есть только queryPurchasesAsync и повторная синхронизация с бэкендом.
Что такое восстановление в Play: queryPurchasesAsync для INAPP и SUBS возвращает активные покупки аккаунта, под которым вошли в Play Store; отправьте токены на сервер, он их проверит и вернёт право доступа
Другой Google-аккаунт: на телефоне с несколькими аккаунтами покупка идёт с того, что выбран в Play; другой аккаунт ничего не восстановит, и кодом это не исправить
Другой ваш аккаунт: сервер может отклонить токен, уже привязанный к другому вашему аккаунту; скажите об этом, а не «восстанавливать нечего»
Расходуемые не возвращаются: списанные монеты исчезают из поля зрения стора, поэтому баланс и живёт на сервере
Как и покупки с другой платформы: покупка на iOS на Android не появится никогда; для этого нужна ваша собственная связка аккаунтов
Не вызов истории: queryPurchaseHistoryAsync отдавал и истёкшие, и списанные покупки, никогда не доказывал владение и удалён в Billing Library 8
iOS: Transaction.currentEntitlements и так содержит разблокировку; кнопка «Восстановить» вызывает AppStore.sync(), который может спросить пароль, поэтому на запуске его не вызывают
Всегда показывайте результат: в том числе «на этом аккаунте восстанавливать нечего»; молчаливое отсутствие реакции становится главным источником обращений про восстановление
Через voidedPurchaseNotification в RTDN и, для подстраховки, через Voided Purchases API (purchases.voidedpurchases.list). Play никогда не трогает вашу базу, поэтому бэкенд сам отзывает право доступа или забирает начисленную расходуемую валюту.
Что считается аннулированным: возвраты от Play или из вашей консоли, чарджбэки банка и отменённые заказы; voidedSource и voidedReason говорят, что именно
Подписки: возврат с отзывом прекращает доступ в Play и присылает SUBSCRIPTION_REVOKED, поэтому повторный запрос subscriptionsv2.get вернёт истёкшее состояние; возврат без отзыва оставляет доступ, и это было решение поддержки
Расходуемые покупки: монеты уже начислены и, возможно, потрачены, поэтому решает ваш сервер: списать баланс, увести его в минус или пометить аккаунт
Опрашивайте тоже: уведомления теряются; раз в день читайте voidedpurchases.list, он покрывает последние 30 дней, и сверяйте по purchaseToken и orderId
Злоупотребление возвратами: купил, потратил, вернул, повторил; считайте аннулированные покупки на аккаунт и перестаньте выдавать в долг тем, кто делает это регулярно
Apple: серверное уведомление REFUND и revocationDate в транзакции; StoreKit к тому же убирает её из currentEntitlements на устройстве
Право доступа достаётся первому вашему аккаунту, который заявил покупку стора: платёж принадлежит аккаунту стора, право доступа принадлежит вашему аккаунту, а связь держит уникальный индекс по токену покупки на вашем сервере.
Связывайте при покупке: .appAccountToken(uuid) в StoreKit, setObfuscatedAccountId() в Play. Apple возвращает appAccountToken в транзакции и в уведомлениях; Play возвращает обфусцированный id в Purchase.accountIdentifiers и в ответе subscriptionsv2.get, но не в RTDN
Это подсказка, а не замок: они есть, только если вы их задали, поэтому у старых покупок и погашенных кодов их нет, а клиент может прислать любой user id
Следите на сервере: уникальный индекс по originalTransactionId или токену покупки, указывающий ровно на один user id; заявка «только вставка» возвращает существующего владельца
Вторая заявка получает конфликт: покажите «подписка уже активна на другом аккаунте» и путь обратно в него, а перенос сделайте логируемым действием поддержки
Family Sharing: у транзакции члена семьи ownershipType == .familyShared; правило, написанное только под покупателя, отрежет законных пользователей
first() берёт первую запись из списка: это может быть сам base plan или offer другого base plan, а у пользователя без права на пробный период записи с ним нет. Play списывает ровно по переданному offerToken, поэтому текст обещал то, чего в токене не было.
Доступность решает стор: Play фильтрует subscriptionOfferDetails по правилу каждого offer (только новые клиенты, апгрейд, по вашим условиям), поэтому вернувшийся бывший подписчик просто не получит записи с пробным периодом
Выбирайте offer осознанно: по basePlanId и тегу offer, заданному в Play Console, или по первой ценовой фазе с priceAmountMicros == 0, а на base plan (offerId == null) откатывайтесь явно
Рисуйте из выбранного: длительность пробного периода и цены берутся из pricingPhases этого offer, и текст не может пообещать то, чего нет в токене
Apple: одно вводное предложение на группу подписок на один Apple Account; проверяйте isEligibleForIntroOffer перед показом. Promotional offers требуют подписи с вашего сервера, а не ключа внутри приложения
Ваши собственные ограничения: доступность привязана к аккаунту стора, поэтому ваше правило «один пробный период на пользователя» живёт на вашем сервере и опирается на ваш user id
Купите годовой base plan с режимом замены: внутри одной подписки разрешены только CHARGE_FULL_PRICE (списать годовую цену сейчас) и WITHOUT_PRORATION (переключить сейчас, новую цену заплатить при продлении). Play создаст новую покупку, у которой linkedPurchaseToken указывает на старую.
Режим важен: обычная покупка внутри одной подписки использует режим замены по умолчанию из Play Console; чтобы переопределить его или переключиться между разными продуктами-подписками, передайте режим: WITH_TIME_PRORATION (по умолчанию), CHARGE_PRORATED_PRICE (только если цена за единицу времени растёт), CHARGE_FULL_PRICE, WITHOUT_PRORATION, DEFERRED (переключить при продлении, обычный выбор для даунгрейда)
На сервере: идите по linkedPurchaseToken, выводите старую запись из оборота, переносите право доступа на новый токен и подтверждайте новую покупку (снова действует правило трёх дней); если пропустить вывод старой записи, старый токен будет давать доступ до своего истечения
Версия библиотеки: начиная с Billing Library 8.1 режим передаётся в SubscriptionProductReplacementParams у продукта; SubscriptionUpdateParams.setSubscriptionReplacementMode объявлен устаревшим
Apple: продукты одной группы подписок переключаются без дополнительных параметров: апгрейд применяется сразу с пропорциональным возвратом, а даунгрейд применяется при следующем продлении
Между «продлилась» и «пропала» оба стора прогоняют окно восстановления (повторные списания, опциональный grace period и account hold у Play), и доступ сохраняется только на время grace.
Состояния Apple: .subscribed, .inGracePeriod, .inBillingRetryPeriod, .expired, .revoked; grace включается в App Store Connect, а без него доступ пропадает при первой неудачной попытке, пока Apple продолжает повторные списания
Состояния Play: ACTIVE, IN_GRACE_PERIOD, ON_HOLD, PAUSED, CANCELED, EXPIRED; grace сохраняет доступ до 30 дней, затем account hold его отзывает, а Play продолжает списывать
В grace: доступ оставляем и показываем закрываемый баннер со ссылкой на починку: AppStore.showManageSubscriptions(in:) или BillingClient.showInAppMessages
В billing retry, hold, expired: доступа нет; показываем призыв починить оплату (retry, hold) или пейволл переоформления (expired), данные пользователя сохраняем
Частая ошибка: считать CANCELED тем же, что EXPIRED: отменивший сохраняет полный доступ до даты окончания. Это конечный автомат, а не булев флаг
Начните с номера заказа из чека: оба стора позволяют серверу превратить его в настоящую покупку, поэтому решением будет поиск на сервере и выдача доступа, а не «купите, пожалуйста, ещё раз».
Google: в чеке номер заказа вида GPA.…; orders.get в Play Developer API возвращает заказ вместе с purchaseToken, а затем subscriptionsv2.get или products.get возвращает состояние
Apple: в чеке из письма есть order ID; GET /inApps/v1/lookup/{orderId} в App Store Server API возвращает подписанные транзакции за ним
Сначала проверьте аккаунт стора: самый частый случай «заплатил и ничего не получил» объясняется покупкой под другим Google- или Apple-аккаунтом, и никакая ваша выдача это не исправит
Почему застряло: заявка до бэкенда не дошла, упала на неизвестном product id после переименования, или токен уже привязан к другому аккаунту того же человека
Каждую неудачную заявку отправляйте в dead-letter: с ключом по идентификатору стора, с повторами по расписанию и алертом на возраст очереди, плюс ежедневная сверка списаний, для которых нет записи о праве доступа
Компенсации пишите в отдельную таблицу: поправите запись, порождённую стором, и следующее уведомление о продлении молча затрёт исправление
price имеет тип Double, и 19.99 * 100 даёт 1998.9999999999998; toLong() отбрасывает дробную часть, а не округляет, поэтому шлюз списывает 1998 центов, то есть на цент меньше, чем показал экран.
Почему: двоичная плавающая точка не может хранить 19.99 точно, умножение сохраняет погрешность, а toLong() усекает к нулю; 0.29 превращается в 28 центов, 4.35 превращается в 434
Исправление: храните деньги как Long в минимальных единицах на всём пути от ответа API до платёжного запроса, тогда суммы и умножения точны; для налогов, скидок и конвертации валют берите BigDecimal с явным RoundingMode, но никогда не Double
Минимальные единицы зависят от валюты: у JPY 0 знаков после запятой, у KWD 3; берите их из Currency.getInstance(code).defaultFractionDigits, а не зашивайте 100
Ещё лучше: пусть сумму считает сервер по своему каталогу, а клиент отправляет только SKU и количество: тогда устаревшая или подменённая цена на клиенте никогда не будет списана. API Play тоже хранят деньги целыми числами: priceAmountMicros имеет тип Long
Как поймать: юнит-тесты с ценами вроде 19.99, 0.29 и 4.35 и правило ревью (или своя проверка detekt), запрещающее Double и Float в денежных типах
Вы меняете цену base plan: новые покупатели платят сразу, текущие остаются в legacy-когорте, пока вы их не перенесёте. Повышение на 30% по умолчанию opt-in: подписчик должен согласиться до продления, иначе Play отменит подписку. Opt-out есть лишь там, где его разрешает Play.
Старые когорты: текущие подписчики сохраняют прежнюю цену, пока вы не закроете когорту, и их можно оставить там навсегда
Opt-in: пользователь должен согласиться в Play до первого списания по новой цене; кто проигнорировал, тот отменяется при этом продлении, и это выглядит как обычный отток
Opt-out: только в подходящих странах и в пределах лимитов Play по размеру и частоте; пользователей уведомляют за 30 или 60 дней, и уведомление в приложении с вашей стороны обязательно; они продлеваются по новой цене, если не отменят
Что видит ваш сервер: autoRenewingPlan.priceChangeDetails у позиции из subscriptionsv2.get и RTDN об изменении цены. На клиенте Billing Library 9 добавила in-app сообщения для opt-in повышений; launchPriceChangeConfirmationFlow удалён в Billing Library 6
Никогда не рисуйте свою цену: formattedPrice локализован под витрину и меняется вместе с ценой, а захардкоженная строка разойдётся с реальным списанием
Apple: повышение в пределах годовых лимитов Apple продлевается после уведомления; более крупное требует согласия, которое показывается через StoreKit Message с причиной .priceIncreaseConsent
В вашей собственной базе, по вашему же user id. Транзакция стора служит только доказательством, а порождённая ею запись о праве доступа остаётся единственным, что читают все клиенты.
Данные стора храните в отдельной таблице: уникальный ключ по originalTransactionId, токену покупки или id подписки Stripe, каждый со ссылкой на пользователя; их может накопиться несколько, а право доступа равно их объединению
Три источника, одна строка: уведомления App Store, RTDN в Play и вебхуки Stripe сходятся в неё, и каждый обработчик идемпотентен, потому что все три повторяют доставку
**Клиенты читают GET /entitlements**: currentEntitlements и queryPurchasesAsync это офлайн-запас и вход для сверки, но не ответ
Двойная оплата: сначала получите право доступа, потом решайте, рисовать ли пейволл; при активной подписке на другой платформе показывайте подсказку по управлению, ведь отменить подписку Apple из Android невозможно
Верьте датам стора: а не часам устройства, и кэшируйте вердикт с небольшим TTL, чтобы перелёт не отрезал платящего
Каждый повтор отправляет новый UUID в качестве ключа идемпотентности, и шлюз считает каждую попытку новым платежом. Если первый вызов уже списал деньги и потерялся только ответ, повтор списывает их второй раз.
Таймаут не равен ошибке: IOException после того, как запрос ушёл с телефона, означает, что результат неизвестен: шлюз мог списать деньги, а ответ пропал в тоннеле, на таймауте чтения или когда приложение ушло в фон
Как работает ключ: шлюз (например, заголовок Idempotency-Key у Stripe) сохраняет результат первого запроса под этим ключом и на каждый повтор возвращает тот же результат; новый ключ на каждую попытку отключает эту защиту
Исправление: создавайте ключ один раз на платёж, сохраняйте его вместе с заказом до первого вызова (order.paymentKey) и переиспользуйте при каждом повторе, после перезапуска процесса и при повторном нажатии «Оплатить»; сервер к тому же отказывает во втором списании по тому же id заказа
Когда повторы закончились: результат неизвестен, а не провален: покажите «проверяем платёж» и запросите статус заказа у своего сервера вместо кнопки «Оплатить снова»
Как поймать: тест с фейковым шлюзом, который списывает деньги и бросает исключение на первом ответе, с проверкой, что списание ровно одно
С license testers в Play Console, сборкой из тестового трека и тестовыми способами оплаты Play; тестовые подписки продлеваются на ускоренных часах, месяц проходит за 5 минут. Они не воспроизведут реальные интервалы повторных списаний банком, налоги и денежные крайние случаи.
License testers: аккаунты из списка в Play Console платят тестовыми инструментами вместо реальных карт; приложению нужна сборка, загруженная во внутренний, закрытый или открытый трек, с тем же package name и подписью. Покупка license tester, не подтверждённая в течение 3 минут, возвращается автоматически
Тестовые инструменты: «всегда одобряет», «всегда отклоняет», медленные карты, которые одобряют или отклоняют через несколько минут, и карта, которая одобряет, а затем делает чарджбэк; медленные дают состояние PENDING, отказ при продлении даёт grace period и account hold, а последняя прогоняет путь отзыва доступа
Ускоренные продления: месячная тестовая подписка продлевается каждые 5 минут, а годовая каждые 30, до шести раз, после чего отменяется
Play Billing Lab: приложение Google для тестировщиков: смена страны Play, повторный бесплатный пробный период, перевод подписки в grace или hold и проверка изменения цены для тестового аккаунта
Бэкенд и юнит-тесты: отправьте тестовое уведомление из Play Console, чтобы проверить связку с Pub/Sub, а BillingClient спрячьте за своим интерфейсом, чтобы конечный автомат прав доступа тестировался юнит-тестами
Чего ничто из этого не воспроизведёт: реальные интервалы повторных списаний банком, налоги и денежные крайние случаи (антифрод-блокировки, семьи с несколькими аккаунтами, региональные способы оплаты). Чтобы тестовые заказы не попали в выручку, читайте тестовую отметку из API (testPurchase в subscriptionsv2, purchaseType у разовых продуктов), а не флаг сборки
iOS: локально StoreKit configuration file и SKTestSession, затем sandbox-аккаунты со своими ускоренными продлениями
Цену нельзя задать из ремоут-конфига (цены живут в сторах), поэтому ценовой тест представляет собой серверный выбор между уже существующими product id плюс запись о покупке, которая помнит, какая ветка её породила.
Назначайте на сервере: закрепляйте вариант за пользователем, возвращая product id, offer token и id варианта; разбивка на клиенте делает ветку непроверяемой при подсчёте
Показывайте цену стора: displayPrice или pricingPhases.formattedPrice; строка цены из вашего конфига врёт в половине витрин
Положите дефолтный конфиг в бинарник: пейволл, который без сети не рисуется, на холодном старте не заработает ничего
Продукты всех веток должны быть живыми: одобренными в обоих сторах до включения флага, а product id вечны: вы удаляете эксперимент, но не созданные им продукты
Атрибуция: пишите вариант рядом с идентификатором покупки, а каждое продление относите к той ветке, где родилась исходная транзакция; события выручки берите из серверных уведомлений, а не с клиента
Чтение результата: когортами по дате начала подписки, в деньгах после вычета комиссии стора, и не раньше чем через полный биллинговый цикл