In-app платежи в iOS. Что нового принес StoreKit2

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Привет, на связи Костя Ищенко — тимлид iOS-команды в Amazing Apps. Наша компания делает мобильные приложения в категории Health & Fitness, которые скачали уже более 100 млн человек.

Сегодня хочу рассказать о неочевидных сложностях в организации in-app платежей в приложениях — как это организовано, что нового принесла iOS 15 (а точнее, StoreKit 2), и почему в этом стоит разбираться прямо сейчас. Статья будет полезна всем, кто связан с разработкой iOS-приложений, не только девелоперам, но и тестировщикам, аналитикам и продактам — как минимум, чтоб лучше понимать процесс.

Со стороны может показаться, что в iOS-приложениях относительно легко сделать прием платежей — вся инфраструктура продумана, со стороны экосистемы есть строгие гайды, написана документация, и ничего не нужно колхозить. Но как обычно, дьявол — в деталях. Какие сложности возникали у нас, как их обходили, и почему всем пора переходить на StoreKit2 — рассмотрим далее.

Контекст

Пересказывать, как в принципе устроен прием in-app платежей в iOS, мы не будем. Все это достаточно подробно описано в девелопер-гайдах Apple. Но под капотом, как обычно, есть (по крайней мере, были до iOS 15) некоторые неочевидные сложности.

Самые значимые из них для нас такие:

  • В официальной документации было недостаточно информации.
    Например, для покупки нужно добавить SKPayment в SKPaymentQueue, используя метод addPayment(). Возвращаемый тип будет Void, и это делает невозможным моментальное реагирования на действия пользователя, поскольку сначала нужно подписаться на SKPaymentTransactionObserver и ждать от него нотификаций об изменениях. Если что-то шло не так, допустим, потеряли соединение с интернетом, возникали проблемы.
  • Неудобное API для работы с подписочной моделью.
    Например, сложная схема проверки покупки — есть куча мест, где все может пойти не по плану, и мало способов проверить, что все работает.

  • Валидация самого чека (receipt) на бэкенде.
    Это проблема, потому что иногда покупка проходила, а сервер Apple не отвечал (а обойтись без него нельзя, по требованиям безопасности).
  • Сложности с синхронизацией между девайсами.
    Если у человека несколько iOS-девайсов, и он хочет использовать одну подписку на два и более из них — это было тяжело поддерживать в iOS 14 и причиняло неудобства разработчикам: не было готового API, нужно было внедрять и поддерживать самостоятельно.

В статье приведем примеры, что и где неочевидно/сложно работало до iOS 15 (точнее, до StoreKit2), и потом — с использованием StoreKit2.

С чего начать прием платежей в iOS-приложениях?

Чтобы начать работать с покупками, для начала нужно их зарегистрировать в AppStore Connect. Короткая инструкция, как это можно сделать:

Рассмотрим проблематику конкретнее на четырех основных кейсах:

  1. Покупка.
  2. Восстановление покупки (рестор).
  3. Валидация чека.
  4. Синхронизация между разными девайсами.

До StoreKit2: покупка

Совершение покупки до StoreKit 2 выглядело следующим образом:

Для получения продуктов мы использовали SKProductRequest:

let productsRequest = SKProductsRequest(productIdentifiers: productIDs)
productsRequest.delegate = self
productsRequest.start()

И после этого слушали SKProductsRequestDelegate:

func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
   print("Loaded list of products...")
   let products = response.products
   guard !products.isEmpty else {
       print("Product list is empty...!")
       print("Did you configure the project and set up the IAP?")
       return
   }
        
   products.forEach {
       print("Found product: \($0.productIdentifier) \($0.localizedTitle) \($0.price.floatValue)")
       }
   }

Когда мы получим список продуктов, мы можем начать работу с ними. Например, чтобы совершить покупку, мы создаем SKPayment для нужного продукта и добавляем его в SKPaymentQueue:

let paymentQueue = SKPaymentQueue.default()


func purchase(_ product: SKProduct) {
    let payment = SKPayment(product: product)
    paymentQueue.add(payment)
}

После нам необходимо подписаться на SKPaymentTransactionObserver и следить за статусом транзакций:

extension StoreHelper: SKPaymentTransactionObserver {
  public func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
    for transaction in transactions {
      switch (transaction.transactionState) {
      case .purchased:
        complete(transaction: transaction)
        break
      case .failed:
        fail(transaction: transaction)
        break
      case .restored:
        restore(transaction: transaction)
        break
      case .deferred:
        break
      case .purchasing:
        break
      }
    }
  }

Важно: после того, как получили статус транзакции, не забывать о ее завершении:

func complete(transaction: SKPaymentTransaction) {
    SKPaymentQueue.default().finishTransaction(transaction)
}

Восстановление покупки

Допустим, пользователь хочет сделать restore своих покупок — например, он удалил приложение, и установил заново через некоторое время:

func restorePurchases() {
    paymentQueue.restoreCompletedTransactions()
}

И, как описано выше, мы слушаем SKPaymentTransactionObserver — если нам вернулся статус .restored, тогда завершаем транзакцию и проводим пользователя дальше по флоу:

private func restore(transaction: SKPaymentTransaction) {
    guard let productIdentifier = transaction.original?.payment.productIdentifier else { return }
    print("restore... \(productIdentifier)")
    paymentQueue.finishTransaction(transaction)
 }

Кажется, пока все просто, не правда ли?

Валидация

А вот и первая проблема: как мы можем узнать, что покупка была совершена правильно и никто не подменил результат покупки? Для таких целей Apple добавили механизм валидации чека (receipt). AppStore-чек — это зашифрованный файл в формате PKCS#7, который содержит в себе информацию обо всех покупках в приложении. Достать его можно из бандла приложения:

Bundle.main.appStoreReceiptURL

Данный чек хранится локально и на серверах Apple. Для того, чтобы удостовериться, что покупка была совершена правильно, необходимо расшифровать локальный чек и сравнить его с чеком, который находится на серверах Apple.

Это можно сделать тремя способами:

  1. Локальная валидация с использованием OpenSSL.
  2. Валидация по запросу в Apple прямо из устройства.
  3. Валидация по запросу в Apple при помощи своего сервера.

Локальная валидация — сложная, необходимо подключать OpenSSL библиотеку к себе в проект.

Валидация напрямую из iOS-устройства — не рекомендуется даже самими Apple, поскольку легко подвергается man-in-the-middle атакам.

К тому же, оба этих способа можно обмануть при помощи перевода времени на устройстве, поэтому самой эффективной считается валидация на своем сервере.

Приложение получает чек из бандла приложения, передает его на свой сервер, а сервер уже парсит его и делает запрос на сервер Apple для валидации, после чего отдает ответ приложению. Данный способ — тоже не идеальный, поскольку серверы Apple не всегда отвечают сразу и приходится держать пользователя на экране покупки либо пропускать в середину приложения с каким-то временным статусом, что может создавать различные неудобства.

Синхронизация между девайсами

Также в старой версии StoreKit не было нормального API для получения истории покупок пользователя или получения статуса подписки: например, о том, истекла она или нет. Для решения этих задач использовался тот же чек (receipt) — приходились передавать его на наш сервер, и уже там определять статус подписки.

Например, для синхронизации покупок между девайсами пользователю нужно было использовать функционал востановления покупки (что само по себе уже не очень). Для этого нам нужно было:

  • стриггерить метод restore, получить receipt;
  • отправить его на бекенд и провалидировать по параметру, который вы сохранили у себя, например, original_transaction_id;
  • потом в базе нужно найти пользователя с таким же полем.

И только после этого можно сказать, что пользователь уже совершал покупку (или нет). Неудобно и для пользователя, и для разработчиков — вынуждало поддерживать кучу лишних методов и бэкенды.

Сторонние решения

Очевидность проблемы подтверждает то, что на рынке есть решения, которые упрощают жизнь разработчикам при работе с покупками — например, RevenueCat.

Рассмотрим те же операции, которые мы смотрели выше, но при помощи RevenueCat.

Для начала работы вам необходимо создать аккаунт и настроить покупки в кабинете RevenueCat, получить API_KEY и entitlement_ID:

struct Constants {
    
    /*
     The API key for your app from the RevenueCat dashboard: https://app.revenuecat.com
     */
    static let apiKey = "api_key"
    
    /*
     The entitlement ID from the RevenueCat dashboard that is activated upon successful in-app purchase for the duration of the purchase.
     */
    static let entitlementID = "premium"
    
}

Для получения списка покупок можно воспользоватся АПИ:

Purchases.shared.getOfferings { (offerings, error) in
            
    if let error = error {
       print(error.localizedDescription)
    }

    if let offerings = offerings {
       print(offerings)
    }
}

Для покупки следует пользоваться тем же объектом Purchases:

Purchases.shared.purchase(package: package) { (transaction, purchaserInfo, error, userCancelled) in
   if let error = error {
       print(error)
   } else { 
       guard let purchaserInfo = purchaserInfo,
                      let entitlement = purchaserInfo.entitlements[Constants.entitlementID],
                      entitlement.isActive else {
                          print("Something went wrong")
                          return
                      }
       print("Payment was successful")
// It is necessary to check the status of the rights, this will mean that the purchase was successful
   }
}

Схожим образом делается и восстановление покупки:

Purchases.shared.restoreTransactions { (purchaserInfo, error) in
// You also need to check the status of the rights in purchaserInfo
}

Валидацию на себя полностью забирает RevenueCat, и она проходит автоматически, когда вы вызываете метод purchasePackage или restoreTransactions, а также чек хранится на серверах RevenueCat и обновляется автоматически.

Как видим, решение от RevenueCat имеет очень простое и удобное API для работы с покупками; бонус — там же можно настроить покупки для Android. Но, как и любое подобное решение, оно платное и может не подойти финансовым планам ваших компаний. Подробнее о тарифах здесь; если коротко — то упущенная выгода будет расти пропорционально росту количества транзакций в ваших приложениях.

В качестве промежуточного вывода можно сказать, что StoreKit первой версии работает нормально, и выполняет все поставленные задачи, но с нюансами. Например, требует много усилий для реализации и поддержки данного решения; нужно подключать команду мобильных разработчиков и команду бэкенда для реализации сервера.

Есть сторонние решения (например, RevenueCat), которые предоставляют удобное API и дашборды для просмотра различной статистики, но за это нужно будет платить деньги, что не всегда подходит.

StoreKit 2

После WWDC21 Apple анонсировала iOS 15, в которую вошел StoreKit 2. Данный фреймворк может существенно улучшить работу с покупками в iOS.

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

Получение списка продуктов

Получить все продукты можно таким образом: поскольку Product теперь структура, то мы можем использовать статический метод products(for: Set<String>)

func requestProducts() async {
    do {
        let storeProducts = try await Product.products(for: productIDs)
        storeProducts.forEach {
        switch $0.type {
        case .consumable:
            print("Product is consumable")
        case .nonConsumable:
            print("Product is non consumable")
        case .autoRenewable:
            print("Product is autorenewable")
        default:
            //Ignore this product.
            print("Unknown product")
        }
    } catch {
        print(error)
    }
}

Соответственно, для самой покупки достаточно:

func purchase(_ product: Product) async throws -> Transaction? {
        //Begin a purchase.
    let result = try await product.purchase()

    switch result {
    case .success(let verification):
        let transaction = try checkVerified(verification)
         //Deliver content to the user.
        await updatePurchasedIdentifiers(transaction)
         //Always finish a transaction.
        await transaction.finish()

        return transaction
    case .userCancelled, .pending:
        return nil
    default:
        return nil
        }
    }
}

Проверка транзакции выглядит следующим образом:

func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
    //Check if the transaction passes StoreKit verification.
    switch result {
    case .unverified:
    //StoreKit has parsed the JWS but failed verification. Don't deliver content to the user.
        throw StoreError.failedVerification
    case .verified(let safe):
   //If the transaction is verified, unwrap and return it.
     return safe
   }
}

Можете наглядно убедиться, насколько стало удобнее реализовать тот же функционал — это и быстрее, и понятнее, и удобнее.

Также крутым апдейтом в StoreKit 2 является новое Subscription API:

  • Мы можем прямо из приложения узнавать апдейты об изменениях статусов подписки (Renewal State) пользователя. Примеры статусов: subscribed, expired, inBillingRetryPeriod, inGracePeriod, revoked.
  • Дополнительная информация о подписках (Renewal Info), например: autoRenewalStatus, gracePeriodExpirationDate. Раньше эту информацию мы могли получить, только распарсив чек с AppStore по ключу: pending_renewal_info.
  • Также добавился список последних транзакций и изменения по ним.

Пример, как это выглядит в коде теперь:

 func listenForTransactions() -> Task<Void, Error> {
        return Task.detached {
            //Iterate through any transactions which didn't come from a direct call to `purchase()`.
            for await result in Transaction.updates {
                do {
                    let transaction = try self.checkVerified(result)

                    //Deliver content to the user.
                    await self.updatePurchasedIdentifiers(transaction)

                    //Always finish a transaction.
                    await transaction.finish()
                } catch {
                    //StoreKit has a receipt it can read but it failed verification. Don't deliver content to the user.
                    print("Transaction failed verification")
                }
            }
        }
    }

Все транзакции доступны при загрузке приложения и автоматически синхронизируются на каждом устройстве. В StoreKit 2 API по-прежнему есть метод sync (), который заставляет транзакции синхронизироваться, однако в большинстве случаев в этом нет необходимости. Вы можете даже удалить кнопку «Восстановить покупки» из пользовательского интерфейса, потому что она бесполезна.

  • Также теперь стало удобно получать историю транзакций, используя ряд полезной информации TransactionSequence:
/// A sequence of every transaction for this user and app.
public static var all: Transaction.TransactionSequence { get }
​
/// Returns all transactions for products the user is currently entitled to
///
/// i.e. all currently-subscribed transactions, and all purchased (and not refunded) non-consumables
public static var currentEntitlements: Transaction.TransactionSequence { get }
​
/// Get the transaction that entitles the user to a product.
/// - Parameter productID: Identifies the product to check entitlements for.
/// - Returns: A transaction if the user is entitled to the product, or `nil` if they are not.
public static func currentEntitlement(for productID: String) async -> VerificationResult<Transaction>?
​
/// The user's latest transaction for a product.
/// - Parameter productID: Identifies the product to check entitlements for.
/// - Returns: A verified transaction, or `nil` if the user has never purchased this product.
public static func latest(for productID: String) async -> VerificationResult<Transaction>?
  • Еще улучшили работу с пользователями. Например, теперь есть возможность сделать refund прямо из приложения:
func refundRequest(for transactionID: UInt64) async {
     guard let windowScene = self.view.window?.windowScene else {
         return
     }
        
     do {
         let result = try await StoreKit.Transaction.beginRefundRequest(for: transactionID, in: windowScene)
         switch result {
         case .userCancelled:
             // Customer canclelled refund request
         case .success:
             // Refund was successful
         @unknown default:
             // Future cases
         }
     }
     catch StoreKit.Transaction.RefundRequestError.duplicateRequest {
         // Duplicated request for refund transaction
     }
     catch StoreKit.Transaction.RefundRequestError.failed {
         // Request failed 
     }    
    }

Или, допустим, показать экран управления подписками — раньше нужно было его искать через настройки телефона, а сейчас есть метод вызвать его прямо из приложения.

Пример, как показать экран с управлением подписками:

func showManageSubscription() async {
   guard let windowScene = self.view.window?.windowScene else 
        return
    }
   do {
       try await AppStore.showManageSubscriptions(in: windowScene)
   } 
   catch {
       //Handle error
   }
}

Валидацию чека Apple теперь делает сама и больше не нужно держать свой сервер для этого. Если в каком-то конкретном случае такое понадобится, то есть способ — при помощи JWS.

JWS состоит из 3-х частей:

  1. header — содержит метаданные об объекте, например, какой алгоритм используется для подписи, и где найти сертификат, используемый для проверки подписи. StoreKit 2 в настоящее время использует алгоритм ECDSA (изначально поддерживается в CryptoKit). Для сертификата StoreKit 2 использует заголовок x5c. Ыся цепочка сертификатов включена в данные JWS, нет подключения к интернету.
  2. payload — основная информация о транзакции, такая как идентификатор транзакции, идентификатор продукта, дата покупки.
  3. signature генерируется с использованием как header, так и payload.

Более подробно можно ознакомиться здесь

Вместо выводов

Как видим, Apple серьезно улучшила работу с покупками и добавила ряд новых улучшений:

  • Новое API для работы с покупками c использованием Swift Modern Concurrency.
  • Валидация на стороне Apple: нет нужды держать свой бэкенд для валидации покупок.
  • Все транзакции автоматически синхронизируются перед запуском приложения.
  • Добавили ряд улучшений для работы с возвратом средств и отменами подписок, что очень круто может помочь вашей саппорт-команде.

От себя хочется добавить: при всем этом не стоит думать, что переехать на новую схему легко и быстро. Менять все, что связано с платежами, как правило, сложно и болезненно. Как минимум, вам необходимо будет использовать iOS 15, что на данный момент может быть затруднительным.

Мы в АmazingApps сейчас экспериментируем с новой моделью и планируем внедрить StoreKit2 в наши приложения; вернемся позже с новыми статьями насчет того, как это в эксплуатации.

Ну и one more thing: не стесняйтесь изучать и внедрять новые решения от Apple. Как правило, это помогает улучшить и ускорить вашу работу. Однако и там не все идеально, поэтому изучайте детали API, рассматривайте неочевидные решения; там тоже есть, что улучшить.

👍ПодобаєтьсяСподобалось19
До обраногоВ обраному5
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Костя, хорошая статья, спасибо! С почином)

Як завжди фреймворк, що по факту не залежить від версії ОС, Apple запихнули в саму ОС, а тому фактично розробники ще 2-3 роки не будуть його використовувати.

Підписатись на коментарі