Irbisa · Шпаргалка

Встроенные покупки во Flutter

in_app_purchase и RevenueCat, типы продуктов, поток покупок, восстановление, состояния подписки, триалы, серверные уведомления, правила сторов, тестирование

Middle-разработчик · 18 вопросов

  1. 01

    Приложение продаёт премиум-подписку, печатную фотокнигу и запись на йогу. Что обязано идти через биллинг App Store и Google Play, а что можно оплатить через Stripe?

    Всё, что открывается и используется внутри приложения (премиум-подписка), обязано идти через биллинг стора.

  2. 02

    Чем отличаются расходуемые и нерасходуемые покупки и автовозобновляемые подписки и что каждый тип обязывает делать приложение?

    Расходуемая покупка (монеты, набор подсказок) тратится и покупается снова, нерасходуемая (вечный премиум, отключение рекламы) покупается один раз и остаётся навсегда, а подписка продлевается каждый период до отмены.

  3. 03

    Пройдите по шагам покупки с плагином in_app_purchase: queryProductDetails, buyNonConsumable, purchaseStream и completePurchase. Что делает каждый шаг?

    Подпишитесь на purchaseStream при старте, загрузите продукты через queryProductDetails, откройте окно стора через buyNonConsumable, обработайте результат, когда он придёт в стрим, и вызовите completePurchase, когда контент выдан.

    final iap = InAppPurchase.instance;// In main(), before runApp: an update with no listener is lostvoid startPurchases() => iap.purchaseStream.listen(_onPurchases); Future<ProductDetails?> loadPremium() async {  if (!await iap.isAvailable()) return null; // no store on this device…
  4. 04

    queryProductDetails на реальном устройстве возвращает все идентификаторы в notFoundIDs. Что вы проверите в каждом сторе?

    Стор пока не знает этих продуктов для этого приложения, этой сборки или этого аккаунта.

  5. 05

    Найдите баг: покупка, сделанная прямо перед тем, как приложение убили, ничего не открывает, а через три дня Google Play возвращает за неё деньги.

    class _PaywallState extends State<Paywall> {  late final StreamSubscription<List<PurchaseDetails>> _sub;  @override  void initState() {    super.initState();    _sub = InAppPurchase.instance.purchaseStream.listen((purchases) async {      for (final p in purchases.where((p) => p.status == PurchaseStatus.purchased)) {        await api.grant(p.verificationData.serverVerificationData);        await InAppPurchase.instance.completePurchase(p);      }    });  }  @override  void dispose() { _sub.cancel(); super.dispose(); }  @override Widget build(BuildContext context) => const PlanList(); // Buy: buyNonConsumable}

    Единственный слушатель покупок живёт на пейволле и умирает вместе с ним, а на Android эту покупку больше никто не доставит.

  6. 06

    Что означает PurchaseStatus.pending (Ask to Buy, оплата наличными) и что должен делать пейволл, пока покупка остаётся в ожидании?

    Пользователь прошёл окно стора, но деньги ещё не списаны: родитель должен одобрить запрос Ask to Buy, или оплата наличными либо через банк ещё не прошла.

    enum PaywallStatus { idle, buying, waiting, owned } final paywallStatus = ValueNotifier(PaywallStatus.idle); Future<void> onPurchase(PurchaseDetails p) async {  switch (p.status) {…
  7. 07

    Найдите баг: после нестабильного соединения у пользователя, который один раз купил 500 монет, на балансе 1000.

    // Dio has a retry interceptor: a timed-out request is sent again, up to 3 timesFuture<void> onPurchase(PurchaseDetails p) async {  if (p.status != PurchaseStatus.purchased) return;  final res = await dio.post('/wallet/add', data: {'coins': 500});  wallet.balance = res.data['balance'] as int;  await InAppPurchase.instance.completePurchase(p);}

    Запрос говорит «добавь 500» и ничего не говорит о том, за какую покупку, поэтому сервер не отличает повтор от новой покупки.

  8. 08

    Как реализовать Restore Purchases на iOS и Android и почему App Review отклоняет платное приложение без возможности восстановить покупки?

    Восстановление заново читает то, чем уже владеет аккаунт стора, и пропускает это через тот же путь «проверить и выдать», что и новую покупку.

    Future<void> onRestoreTapped() async {  final iap = InAppPurchase.instance;  if (Platform.isIOS) {    try {      // AppStore.sync(): may ask for the Apple Account password, so only on a tap      await iap.getPlatformAddition<InAppPurchaseStoreKitPlatformAddition>().sync();…
  9. 09

    Как приложение при запуске узнаёт, что подписка ещё активна, и что показывать без сети или когда ваш сервер лежит?

    Оно спрашивает ваш сервер, который слышал от сторов о каждом продлении и возврате, и кеширует ответ вместе с датой окончания.

    class EntitlementRepository {  EntitlementRepository(this._api, this._cache);  final Api _api;  final EntitlementCache _cache;   /// First frame, no network: the server's last answer, until its period ends.…
  10. 10

    in_app_purchase или purchases_flutter от RevenueCat: что RevenueCat снимает с вас и чего это стоит?

    RevenueCat заменяет серверную половину, которую иначе пришлось бы писать самим: проверку покупок в обоих сторах, серверные уведомления, состояния подписок и единое право доступа на всех платформах.

    Future<void> initPurchases(String userId) async {  final config = PurchasesConfiguration(    Platform.isIOS ? 'appl_xxx' : 'goog_xxx', // public SDK keys, safe to ship  )..appUserID = userId; // your account id: the same customer on every platform  await Purchases.configure(config);  Purchases.addCustomerInfoUpdateListener(_apply); // renewals, refunds, restores…
  11. 11

    Этот пейволл собирает подпись с ценой из rawPrice. Что видят пользователи за пределами США и как правильно показывать цену?

    // yearly: the ProductDetails for 'pro_yearly' from queryProductDetailsWidget yearlyPriceLabel(ProductDetails yearly) {  final perMonth = yearly.rawPrice / 12;  return Text('Just \$${perMonth.toStringAsFixed(2)} a month');}

    Каждый видит знак доллара перед числом в валюте своей витрины: подписчик из Германии, который платит 59,99 € в год, читает «Just $5.00 a month», а подписчик из Японии, который платит ¥7,000, читает «Just $583.33 a month».

  12. 12

    Как предложить 7-дневный бесплатный период только тем, кому он положен, и в App Store, и в Google Play?

    Настройте триал в каждом сторе и дайте стору решить, кому он положен: Google Play возвращает только те предложения, которые этот пользователь может купить, а на iOS вы спрашиваете StoreKit, прежде чем показывать текст про триал.

    /// The entry to sell with a trial, or null: then the paywall shows no trial copy.Future<ProductDetails?> trialProduct(List<ProductDetails> details) async {  final play = details.whereType<GooglePlayProductDetails>().toList();  if (play.isNotEmpty) {    // Android: one entry per offer, and Play lists only offers this user may buy    return play.where((d) {…
  13. 13

    У подписчика истекла карта до продления. Через какие состояния (grace period, billing retry, account hold, expired) он пройдёт и что показывает приложение в каждом?

    Стор пытается списать деньги до 60 дней.

  14. 14

    Продления, отмены и возвраты происходят, пока приложение закрыто. Как о них узнаёт бэкенд и почему сообщать о них не может само приложение?

    Сторы сами вызывают ваш сервер: Apple шлёт подписанные App Store Server Notifications V2, Google публикует Real-time Developer Notifications в топик Cloud Pub/Sub.

  15. 15

    Как тестировать покупки, не тратя денег: sandbox-аккаунты, файлы конфигурации StoreKit, лицензионные тестировщики Play, и чего не воспроизведёт ни один из них?

    На iOS начните с файла конфигурации StoreKit в Xcode, затем берите sandbox-аккаунты и TestFlight с настоящими продуктами; на Android добавьте лицензионных тестировщиков в Play Console и покупайте настоящие продукты.

  16. 16

    Пользователь оформил подписку на iPhone, а потом вошёл в ваше Android-приложение и на сайт. Где живёт подписка и как о ней узнаёт каждая платформа?

    Покупка живёт в системе Apple, а право доступа должно жить на вашем сервере, с ключом по id вашего аккаунта.

    // accountId: a UUID your backend gives the user at sign-up and never changesFuture<bool> subscribe(ProductDetails plan, String accountId) =>    InAppPurchase.instance.buyNonConsumable(      purchaseParam: PurchaseParam(        productDetails: plan,        // iOS: becomes StoreKit's appAccountToken (dropped silently unless a UUID);…
  17. 17

    Как перевести подписчика с месячного плана на годовой в обоих сторах и что происходит со старой подпиской и уже уплаченными деньгами?

    Продавайте это как смену плана, а не как вторую покупку.

    Future<void> switchToYearly(ProductDetails yearly, PurchaseDetails current) async {  final iap = InAppPurchase.instance;  if (current is GooglePlayPurchaseDetails) {    // Android: name the purchase being replaced, or Play may sell a second one    await iap.buyNonConsumable(      purchaseParam: GooglePlayPurchaseParam(…
  18. 18

    Продакт хочет протестировать три дизайна пейволла и две цены. Что нужно в приложении и на сервере и почему нельзя менять цену живого продукта для одного пользователя?

    Цена в сторе принадлежит продукту и стране, а не пользователю, поэтому каждой тестируемой цене нужен свой продукт.