Всё, что открывается и используется внутри приложения (премиум-подписка), обязано идти через биллинг стора. Физические товары и услуги, которые потребляются вне приложения (фотокнига, занятие в студии), через него проводить нельзя: их оплачивают через Stripe, Apple Pay или Google Pay.
Цифровое значит биллинг стора: подписки, разблокировки, монеты и контент внутри приложения; этого требуют и правило Apple 3.1.1, и Payments policy Google Play, а за спрятанную веб-оплату приложение отклоняют или снимают с публикации
Физическое значит ваш платёжный провайдер: книгу печатают и доставляют, занятие проходит в зале; правило Apple 3.1.3(e) запрещает для них встроенные покупки, а оплату принимает flutter_stripe или пакет pay
Пограничный случай: живое занятие, которое транслируется в приложении группе, Apple считает цифровым, и ему нужна встроенная покупка, а индивидуальную сессию в реальном времени (тренер по видео) разрешает оплачивать другими способами
Региональные исключения: приложениям на витрине США можно ссылаться на веб-оплату, а в ЕС по DMA разрешены альтернативные платежи, у каждого варианта свои правила и комиссии; они добавляют путь, но не отменяют биллинг стора
FlutterMiddle
Встроенные покупки во Flutter
in_app_purchase и RevenueCat, типы продуктов, поток покупок, восстановление, состояния подписки, триалы, серверные уведомления, правила сторов, тестирование
Закрыто вопросов
0 / 18
- 0Повторить
Вопросы
Расходуемая покупка (монеты, набор подсказок) тратится и покупается снова, нерасходуемая (вечный премиум, отключение рекламы) покупается один раз и остаётся навсегда, а подписка продлевается каждый период до отмены. От типа зависит, что вы храните, восстанавливаете и отслеживаете.
Расходуемая: после расходования стор о ней забывает, поэтому баланс хранит ваш сервер; Restore монеты не вернёт. Покупается через buyConsumable
Нерасходуемая: стор помнит её всегда, поэтому приложение обязано предлагать Restore Purchases; покупается через buyNonConsumable
Автовозобновляемая подписка: доступ зависит от времени, поэтому вы отслеживаете окончание, продления, неудачные списания и возвраты, которые происходят при закрытом приложении и приходят на сервер уведомлениями
Невозобновляемая подписка (App Store, например абонемент на сезон): стор не отслеживает срок, поэтому дату окончания хранит ваш сервер; ближайший аналог в Play, предоплаченный план (prepaid plan), срок отслеживает
Кто определяет тип: Apple фиксирует его для каждого продукта в App Store Connect; в Google Play разовый продукт становится расходуемым только потому, что ваш код его расходует, поэтому монеты, купленные через buyNonConsumable, остаются во владении, и купить их снова нельзя
Подпишитесь на purchaseStream при старте, загрузите продукты через queryProductDetails, откройте окно стора через buyNonConsumable, обработайте результат, когда он придёт в стрим, и вызовите completePurchase, когда контент выдан.
**Сначала purchaseStream**: в этот broadcast-стрим приходят все покупки и восстановления (на iOS ещё и транзакции, не завершённые в прошлый раз), а событие без слушателя теряется, поэтому подписывайтесь в main(), а не на пейволле
**queryProductDetails**: принимает идентификаторы из App Store Connect и Play Console и возвращает ProductDetails с отформатированной стором ценой price, а также notFoundIDs для идентификаторов, которых стор не знает
**buyNonConsumable**: его bool говорит лишь о том, что процесс покупки запущен; заплатил ли пользователь, станет известно позже из PurchaseStatus в стриме
Каждый статус: pending (ждать, ничего не выдавать), purchased или restored (проверить на сервере, затем выдать), error, canceled
**completePurchase**: завершает транзакцию на iOS и подтверждает (acknowledge) покупку на Android; вызывайте после выдачи и никогда при pending. Покупку, не подтверждённую за три дня, Play возвращает
Стор пока не знает этих продуктов для этого приложения, этой сборки или этого аккаунта. Почти всегда дело в настройке, а не в коде: не подписано соглашение, продукт не готов, не совпадает bundle id или имя пакета, либо Play ни разу не видел это приложение.
Сами идентификаторы: они чувствительны к регистру и принадлежат одному приложению; опечатка или идентификатор из другого стора молча попадают в notFoundIDs
App Store Connect: соглашение Paid Apps должно быть активно, с заполненными банковскими и налоговыми данными; у каждого продукта должны быть цена и локализация; bundle id должен совпадать с приложением в App Store Connect и иметь capability In-App Purchase
Google Play: приложение с тем же applicationId хотя бы раз загружено в Play Console (хватит internal testing), продукт активен, а аккаунт Play на устройстве входит в лицензионные тестировщики (тогда работает даже отладочная сборка, установленная напрямую) или установил сборку из Play; сборку с applicationIdSuffix вроде .debug Play считает другим приложением
**isAvailable() возвращает false: спрашивать некого (телефон без сервисов Google) или покупки ограничены; покажите состояние «недоступно», а не пустой список
Файлы StoreKit**: файл конфигурации StoreKit загружает продукты на симуляторе, но они существуют только в Xcode; если там всё работает, а в TestFlight нет, проблема снова в App Store Connect
Единственный слушатель покупок живёт на пейволле и умирает вместе с ним, а на Android эту покупку больше никто не доставит. Play так и не получает подтверждения, поэтому через три дня возвращает пользователю деньги и отзывает покупку.
Как она теряется: пользователь платит, а процесс умирает до того, как закончатся grant и completePurchase (приложение смахнули или система убила его, пока на экране было приложение банка для подтверждения); при следующем запуске никто не слушает, пока не откроют пейволл
Android не присылает повторно: плагин лишь пересылает колбэк покупок Play, поэтому незавершённая покупка вернётся, только если вы спросите через queryPastPurchases() или restorePurchases(); Play советует запрашивать покупки при запуске и при возврате в приложение
iOS прячет баг: StoreKit повторяет незавершённые транзакции, когда появляется слушатель, поэтому на iPhone покупка откроется с опозданием, при следующем открытии пейволла, и деньги никто не вернёт
Ещё теряется на Android: покупка в pending, которая проходит через несколько часов, потому что broadcast-стрим выбрасывает события, которые никто не слушает
Исправление: один PurchaseService, запущенный в main(), который слушает всё время жизни приложения и на Android при запуске подбирает неподтверждённые покупки; выдачу на сервере сделайте идемпотентной, ведь теперь одна покупка может прийти дважды
Пользователь прошёл окно стора, но деньги ещё не списаны: родитель должен одобрить запрос Ask to Buy, или оплата наличными либо через банк ещё не прошла. Покажите спокойное состояние ожидания, ничего не выдавайте и обработайте настоящий результат, когда бы он ни пришёл, хоть через несколько дней.
Откуда берётся: на iOS из-за Ask to Buy в «Семейном доступе» или покупки, которой нужно дополнительное действие покупателя; в Google Play из-за отложенных способов оплаты, например наличными в магазине у дома
Ничего не выдавать и не завершать: оплаченной транзакции, которую можно завершить, ещё нет, а на Android pendingCompletePurchase уже равен true, поэтому проверяйте статус, а не флаг; трёхдневное окно подтверждения в Play начинается, только когда покупка становится PURCHASED
Интерфейс: не ошибка и не вечный спиннер, а «Ждём подтверждения», и пейволл отпускает пользователя, чтобы тот пользовался приложением дальше
Результат приходит позже, часто после перезапуска, поэтому доставляет его слушатель уровня приложения (и запрос покупок при запуске на Android), а не пейволл
Отказ может прийти молча: отклонённый Ask to Buy не присылает ничего, поэтому состояние ожидания должно истекать само, например при следующем запуске
Как проверить: тестовая карта Play «Slow test card, approves after a few minutes» и Ask to Buy в файле конфигурации StoreKit
Запрос говорит «добавь 500» и ничего не говорит о том, за какую покупку, поэтому сервер не отличает повтор от новой покупки. Запрос, который на самом деле прошёл, но не дождался ответа, отправляется снова, или стор заново доставляет незавершённую покупку, и каждая копия добавляет 500.
Два пути к дублю: ответ потерялся после того, как сервер всё записал, и интерсептор повторов отправил запрос снова; или completePurchase так и не вызвали, и покупка вернулась при следующем запуске или восстановлении
Исправление, идемпотентность по идентификатору стора: отправляйте токен покупки; сервер проверяет его у Apple или Google и записывает выдачу под уникальным ключом (transactionId в App Store, сам purchaseToken в Play, но не orderId, которого нет у покупок по промокоду), так что повтор ничего не меняет
Количество решает сервер по productId: клиент, который шлёт coins: 500, с тем же успехом пришлёт 50 000
Расходовать после выдачи: при autoConsume: true по умолчанию плагин на Android расходует набор ещё до вашего обработчика, и если выдача упала, остаётся оплаченная покупка без монет; на Android передавайте autoConsume: false (плагин iOS требует true через assert) и расходуйте, когда сервер ответил
Показывайте баланс сервера: balance += 500 на клиенте расходится с правдой при первом же повторе
Восстановление заново читает то, чем уже владеет аккаунт стора, и пропускает это через тот же путь «проверить и выдать», что и новую покупку. Apple требует такую возможность (правило 3.1.1), потому что пользователь на новом iPhone или после переустановки не должен платить дважды.
Что возвращается: нерасходуемые покупки и активные подписки приходят в purchaseStream со статусом PurchaseStatus.restored; израсходованные монеты не возвращаются никогда, поэтому их баланс живёт на вашем сервере
iOS: кнопка Restore на пейволле и в настройках; вызывайте sync() из платформенного дополнения StoreKit (под капотом AppStore.sync()) только по нажатию, потому что он может спросить пароль Apple Account
Android: правила про кнопку в Play нет, но пользователи ждут покупку обратно после переустановки, поэтому запрашивайте покупки при запуске; кнопка с restorePurchases() всё равно помогает поддержке
Пустой результат в порядке вещей: тогда в стрим приходит пустой список, а не событие restored, поэтому не ждите его; спросите сервер и напишите, какой аккаунт стора проверили
Оставьте её и при своих аккаунтах: покупка принадлежит аккаунту Apple или Google, который мог купить её под другим логином, поэтому ваш вход не заменяет восстановление
Почему отклоняют: ревьюеры ищут эту возможность на пейволле, и платное приложение без неё часто получает отказ по 3.1.1
Оно спрашивает ваш сервер, который слышал от сторов о каждом продлении и возврате, и кеширует ответ вместе с датой окончания. Без сети приложение показывает премиум, пока эта дата не прошла: за этот период пользователь заплатил.
**purchaseStream ничего не запрашивает: он сообщает о транзакциях по мере того, как они происходят; тихий стрим при запуске означает, что ничего не произошло, а не что у пользователя ничего нет
Не восстанавливайте при каждом запуске**: restorePurchases() прогоняет каждую купленную вещь через ваш обработчик и ваш сервер; оставьте его для кнопки Restore
Кешируйте ответ, а не флаг: храните active, productId и expiresAt; голый isPremium = true не истекает никогда, и отменённая подписка навсегда остаётся премиумом на этом телефоне
Обновляйте при запуске, при возврате из фона, после покупки или восстановления и когда пуш сообщает, что право изменилось; все экраны читают один репозиторий
Офлайн означает компромисс: кто переведёт часы назад, получит немного лишнего доступа, и это дешевле, чем запереть платящего пользователя в самолёте; следующая проверка в сети всё исправит
С RevenueCat: Purchases.getCustomerInfo() возвращает закешированную копию и сам её обновляет
RevenueCat заменяет серверную половину, которую иначе пришлось бы писать самим: проверку покупок в обоих сторах, серверные уведомления, состояния подписок и единое право доступа на всех платформах. Платите вы долей выручки сверх бесплатного порога, а между вами и данными о покупках встаёт вендор.
**С in_app_purchase: тонкая прослойка над StoreKit и Play Billing; проверка, продления, возвраты, grace period и доступ с разных устройств остаются задачей вашего бэкенда
Что добавляет RevenueCat**: CustomerInfo с активными правами (entitlements), offerings, которые меняют продукты на пейволле из дашборда без релиза, эксперименты, вебхуки, графики выручки и веб-покупки через Stripe в том же праве доступа
Права важнее продуктов: приложение проверяет entitlements.active['pro'], поэтому новая цена или годовой продукт эту проверку не затрагивают
Цена: бесплатно до 2500 долларов отслеживаемой выручки в месяц, затем 1% от неё; их аптайм и релизы SDK становятся вашей зависимостью, а история покупок живёт в их системе, поэтому с первого дня складывайте их вебхуки в свою базу
Когда хватит плагина: только разовые разблокировки, команда, у которой уже есть биллинговый бэкенд, или данные о покупках, которые не должны покидать ваши серверы
Каждый видит знак доллара перед числом в валюте своей витрины: подписчик из Германии, который платит 59,99 € в год, читает «Just $5.00 a month», а подписчик из Японии, который платит ¥7,000, читает «Just $583.33 a month». Сумма, которую реально спишут, не показана нигде.
**Что хранит rawPrice**: голое число в валюте currencyCode, то есть в валюте страны App Store или Play пользователя, а не локали телефона и не вашего прайс-листа
**Списываемая сумма лежит в price: стор уже отформатировал её в нужной валюте и виде для этой витрины
Производной цене нужны оба поля**: NumberFormat.simpleCurrency с name: currencyCode и локалью пользователя даёт «5,00 €» и «¥583»; подпишите её как примерную и сделайте мельче списываемой суммы, потому что App Review (правило 3.1.2) отклоняет пейволлы, где вычисленная цена за месяц заметнее того, что списывают
Ловушка на Android: для подписки плагин возвращает по записи на каждое предложение, и price берётся из первой фазы этого предложения; возьмите запись триала, и rawPrice окажется 0, а подпись станет «Just $0.00 a month». Обычную цену берите из записи базового плана (offerId == null)
Никаких зашитых цен в текстах или remote config: они различаются по странам и меняются вместе с налогами и курсами
Настройте триал в каждом сторе и дайте стору решить, кому он положен: Google Play возвращает только те предложения, которые этот пользователь может купить, а на iOS вы спрашиваете StoreKit, прежде чем показывать текст про триал. Пейволл обещает триал, только если проверка ответила «да».
App Store: триал оформляется как вводное предложение (introductory offer) подписки; аккаунт Apple получает одно такое предложение на группу подписок, поэтому второй продукт с триалом в той же группе второго триала не даст. StoreKit применяет его при покупке, а isIntroductoryOfferEligible из in_app_purchase_storekit сообщает заранее
Google Play: триал задаётся предложением на базовом плане с правилом вроде «никогда не было подписок»; queryProductDetails возвращает по ProductDetails на каждое предложение, которое пользователю положено, а покупка этой записи отправляет её offerToken
Ищите предложение по тегу (trial-7d, задаётся в Play Console), а не по порядку в списке или нулевой цене
Предложения с решением разработчика (developer-determined): Play отдаёт их всем и ничего не проверяет, поэтому используйте их, только когда право решает ваш сервер
Скажите, что будет потом: цена после триала, дата её начала и способ отмены должны быть на пейволле; правила подписок обоих сторов отклоняют кнопку «Start free», которая это прячет
Злоупотребления: новый аккаунт Apple или Google получает новый триал; если это важно, записывайте триалы и по своему аккаунту на сервере
Стор пытается списать деньги до 60 дней. В льготный период (grace period) доступ сохраняется, а приложение просит обновить способ оплаты; после него доступ закрывается, пока повторные попытки продолжаются; удачная попытка возвращает доступ, иначе подписка истекает.
App Store: повторные списания (billing retry) идут до 60 дней, а опциональный Billing Grace Period (3, 16 или 28 дней) сохраняет доступ в первые дни; сервер получает DID_FAIL_TO_RENEW (подтип GRACE_PERIOD), GRACE_PERIOD_EXPIRED, затем DID_RENEW или EXPIRED
Google Play: grace period (включён по умолчанию), затем account hold, по умолчанию 60 дней в сумме: SUBSCRIPTION_IN_GRACE_PERIOD, SUBSCRIPTION_ON_HOLD, затем SUBSCRIPTION_RECOVERED или SUBSCRIPTION_EXPIRED
Grace period: премиум включён, а баннер («Оплата не прошла, обновите её, чтобы сохранить Pro») открывает настройки подписки в сторе
Retry или hold: премиум выключен, экран объясняет причину и предлагает починить оплату вместо продажи нового плана; запрос покупок на Android не возвращает подписку в account hold, поэтому знает о ней только ваш сервер
Восстановлена или истекла: всё как обычно или обычный пейволл; не отключайте доступ во время grace period: по правилам обоих сторов пользователь сохраняет его до конца этого срока
Подсказки, которые рисует стор: in-app messaging в Play (showInAppMessages в Android-плагине) и окно StoreKit о проблеме с оплатой
Сторы сами вызывают ваш сервер: Apple шлёт подписанные App Store Server Notifications V2, Google публикует Real-time Developer Notifications в топик Cloud Pub/Sub. Приложение не годится в источник: оно может не запускаться неделями, а запустившись, выполняет код под контролем пользователя.
Apple: ваш HTTPS-эндпоинт получает JWS в signedPayload; проверьте цепочку сертификатов до корневого сертификата Apple, затем действуйте по notificationType и subtype (DID_RENEW, EXPIRED, REFUND)
Google: сообщение несёт только purchaseToken и тип вроде SUBSCRIPTION_RENEWED, поэтому за настоящим состоянием вызывайте purchases.subscriptionsv2.get; возвраты и чарджбэки приходят как уведомления об аннулированных покупках (voided purchases)
Почему не приложение: продление происходит на серверах стора, возврат одобряет поддержка Apple, отменяют в системных настройках, а результат нужен и вашему Android-приложению, и сайту
Дубликаты: оба стора повторяют сообщение, на которое вы не ответили 2xx (Apple до пяти раз за шесть дней), поэтому обработчик должен быть идемпотентным (notificationUUID у Apple), а каждое сообщение означает «перечитай состояние», а не дельту
Потерянные сообщения: сверяйтесь через эндпоинт истории уведомлений Apple и периодически перепроверяйте активные подписки через API каждого стора
Затем достучаться до устройства: обновите право доступа, затем отправьте тихий пуш или дайте приложению обновиться при возврате из фона
На iOS начните с файла конфигурации StoreKit в Xcode, затем берите sandbox-аккаунты и TestFlight с настоящими продуктами; на Android добавьте лицензионных тестировщиков в Play Console и покупайте настоящие продукты. Ни один вариант не воспроизводит настоящие банки, возвраты и тайминги продакшена.
Файл конфигурации StoreKit: продукты описаны в Xcode, App Store Connect не нужен; выберите файл в схеме Runner (Run, Options) и запускайте из Xcode. Он умеет имитировать ошибки, Ask to Buy и возвраты, но его транзакции подписаны локально, поэтому проверка на вашем сервере у Apple их отклоняет
Sandbox: sandbox-аккаунт Apple из App Store Connect бесплатно покупает настоящие продукты, продления ускорены, а уведомления идут на ваш sandbox-URL
TestFlight: настоящие продукты, бесплатно для тестировщиков; подписка продлевается раз в сутки, не больше шести раз; ближе всего к продакшену на iOS
Лицензионные тестировщики Play: добавляются в Play Console и платят тестовыми картами («always approves», «always declines», медленные карты для pending); продления занимают минуты, уведомления приходят. Сборку можно ставить напрямую, даже отладочную, если её applicationId совпадает с приложением в Play Console
Чего не воспроизводит ни один: настоящие отказы банков и 3-D Secure, возвраты, которые решает поддержка Apple, реальную длину grace period и первое ревью, где новая встроенная покупка отправляется вместе с версией приложения
Покупка живёт в системе Apple, а право доступа должно жить на вашем сервере, с ключом по id вашего аккаунта. Покупку на iOS привязывают к этому аккаунту в момент оплаты, а Android и веб спрашивают ваш сервер, потому что Google Play о ней никогда не слышал.
Привязка покупки к аккаунту
Передавайте UUID аккаунта в applicationUserName: StoreKit 2 сохраняет его как appAccountToken (любая другая строка молча отбрасывается), а Play как obfuscatedAccountId; подписанные транзакции и уведомления Apple несут его, поэтому сервер знает, чьё это продление
Сервер проверяет покупку, записывает строку права доступа (source: app_store, originalTransactionId, expiresAt) и держит её актуальной по уведомлениям
Что показывает каждая платформа
Android: Restore ничего не находит, и это правильно; приложение показывает премиум по данным сервера и вместо кнопки покупки пишет «Управляется в App Store на вашем iPhone», чтобы никто не заплатил дважды
Веб: то же право доступа; подписка через Stripe, купленная на сайте, пишет в ту же таблицу, и оба приложения её видят
Отмена и возвраты возможны только в сторе, который продал подписку, поэтому ссылка «Управлять подпиской» зависит от source
Пограничные случаи
Один аккаунт Apple, два ваших аккаунта: если второй логин восстанавливает ту же покупку, нужно правило (перенести её или отказать с объяснением), которое сервер применяет по originalTransactionId
Куплено без входа: привязать покупку не к чему; просите войти до пейволла или привязывайте её при первом входе
Продавайте это как смену плана, а не как вторую покупку. На iOS оба плана лежат в одной группе подписок и StoreKit меняет один на другой; на Android вы передаёте текущую покупку в ChangeSubscriptionParam с режимом замены, а Play засчитывает остаток или списывает разницу.
App Store
Одна группа означает один активный план. Порядок уровней определяет время перехода: переход на более высокий уровень считается апгрейдом и начинается сразу, а неиспользованная часть месяца возвращается пропорционально; тот же уровень с большей длительностью считается кроссгрейдом и начинается со следующего продления
Поставьте годовой план выше месячного, если он должен начаться сразу; сервер получит DID_CHANGE_RENEWAL_PREF с подтипом UPGRADE, а originalTransactionId останется прежним
Google Play
Деньги определяет режим замены. Между двумя базовыми планами одной подписки Play принимает только chargeFullPrice (сразу списать за год и перенести оставшееся время) и withoutProration (переключить сразу, а годовую цену списать в следующую дату оплаты), а если режим не передан, берёт значение по умолчанию из Play Console
Между двумя разными подписками можно также использовать withTimeProration (неиспользованный месяц превращается в дополнительное время) или deferred (переключение при следующем продлении); chargeProratedPrice требует более высокой цены за единицу времени, а у годового плана со скидкой её нет
Смена создаёт новый токен покупки, у которого linkedPurchaseToken указывает на старый: перенесите право доступа на новый токен и перестаньте учитывать старый, иначе пользователь посчитается двумя подписчиками; новую покупку подтвердите как обычно
В приложении
Без changeSubscriptionParam вызов buyNonConsumable становится новой покупкой: если месячный и годовой планы оформлены в Play отдельными продуктами, пользователь заплатит за оба
До нажатия покажите, когда начнётся годовой план и сколько спишут сегодня
Цена в сторе принадлежит продукту и стране, а не пользователю, поэтому каждой тестируемой цене нужен свой продукт. Сервер один раз назначает пользователю вариант, пейволл рисует макет и продукты этого варианта, а результат считают по проверенным транзакциям, а не по нажатиям.
Почему один продукт на цену
App Store Connect и Play Console задают цену продукта на витрину сразу для всех; изменение сдвигает всех новых покупателей разом, а повышение для текущих подписчиков требует уведомления и часто их согласия
Поэтому заведите pro_monthly_499 и pro_monthly_699 (на iOS в одной группе подписок, чтобы никто не держал обе); после теста обе остаются активными, и каждый купивший продлевает по своей цене
Цена для отдельного пользователя бывает только в виде предложения поверх базовой цены (promotional offer у Apple, предложение developer-determined в Play), а это тест скидки, а не цены
В приложении
Пейволл, управляемый данными: сервер или Remote Config возвращает {variant, layout, productIds}, приложение запрашивает эти идентификаторы, и новый вариант выходит без релиза
Назначение закреплено за аккаунтом и делается до первого показа пейволла, с контрольной группой; каждое событие пейволла несёт вариант
На сервере
Сохраняйте вариант вместе с каждой проверенной транзакцией и сравнивайте конверсию, переход из триала в оплату, возвраты и продления по вариантам; более дешёвая цена с лучшей конверсией к третьему месяцу всё равно может заработать меньше
Offerings и Experiments в RevenueCat или Firebase A/B Testing поверх Remote Config закрывают большую часть этого
В ЕС цену, персонализированную автоматическим принятием решений, нужно раскрывать (setIsOfferPersonalized в Play)