Подписки, чеки и StoreKit в iOS 14

Из всех способов получить деньги за работу, которую вы вкладываете в разработку приложений, лучшим на сегодняшний день является подписка. В статье я надеюсь рассмотреть лучшие практики, изложенные в докладе WWDC2019, и связать их с докладом WWDC2020 по архитектуре подписок. Завершу свое обсуждение примером кода.

Предыстория

Вся индустрия IT приложений движется к подписке уже более десяти лет. Amazon был одним из первых игроков с AWS в 2006 году. Microsoft и Apple запустили продукты в 2011 году: Office 365 и iCloud. И даже Adobe присоединилась к вечеринке в 2013 году, когда их Creative Suite вышел в интернет.

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

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

Подписки и другие покупки в приложении можно продавать с помощью одного или нескольких из этих трех классических путей:

  • Во время презентации возможностей приложения при первом запуске.
  • С помощью модели freemium, предлагающей уменьшенную функциональность, но возможность обновления до подписки в приложении.
  • Используя “пэйволмат” (подход с дозированной оплатой), который позволит использовать функционал только в течение определенного периода.

Но есть вариант лучше  —  перед загрузкой вашего приложения можно начать продвигать в App Store подписки.

Типы предложений подписок

Существует три типа подписок, которые вы можете продавать: ознакомительные и рекламные предложения и, в соответствии с iOS 14, коды предложений.

Можно предоставить все три варианта одновременно, если это имеет смысл. Эта таблица, взятая с веб-страницы для разработчиков, сравнивает и противопоставляет различные типы.

Сравнение предложений подписок

iOS 14, iPadOS 14 и более поздние версии

Сохранение подписчиков

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

В WWDC2019 на эту тему говорили не менее, чем о шести способах, как удержать клиентов на крючке, и представили этот ряд иконок:

Что он означает?

Возврат

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

Удержание

В этом случае клиент изменяет автоматическое обновление подписки с true на false. Нужно попытаться выяснить, что не так, и снова попытаться заставить их изменить свое мнение с помощью предложения автообновления.

Предложения для сохранения

Здесь у вас есть веские основания полагать, что клиент может уйти. Это балансирующий акт, но нужно удержать его и сделать предложение, чтобы попытаться переубедить.

Обновление/даунгрейд

Вам нужно больше денег, заставьте их обновиться. Сделайте им новое предложение.

Обслуживание клиентов

Случай, когда клиенты жалуются на потерю обслуживания. Это может побудить их отказаться от подписки. Чтобы предотвратить это, окажите жест доброй воли для компенсации проблемы.

Лояльность

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

Все способы выглядят и звучат великолепно в теории, но как их применить на практике? В WWDC2020 связывают эти предложения с пунктом, в котором ваши подписчики находятся в путешествии, которое можно более подробно рассмотреть, используя чек с их текущим статусом подписки.

Скачав самый последний чек, вы можете решить, как адаптировать предложения, чтобы удержать подписчика. Вот список, взятый из документации Apple, о том, что означают все ошибки в указанном чеке (перевод ниже).

Свойства
enviroment string

Среда, для которой был сгенерирован чек. 
Возможные значения: Sandbox, Production.

is-retryable boolean

Индикатор того, что во время запроса произошла ошибка. Значение 1 указывает на временную проблему; повторите проверку этого чека позже. 0 указывает на неразрешимую проблему; не повторяйте проверку для этого чека. Коды состояния 21100–21199.

latest_receipt byte

Последний закодированный Base64 чек приложения. Возвращается только для чеков, которые содержат автоматически возобновляемые подписки.

latest_receipt_info [responseBody.Latest_receipt_info]

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

pending_renewal_info [responseBody.Pending_renewal_info]

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

receipt [responseBody.Receipt] 

JSON представление чека, который был отправлен на проверку. 

status [status]

0, если чек действителен, или код состояния есть ошибка. Последний отражает состояние чека приложения в целом. См. раздел status для получения возможных кодов состояния и описаний.

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

Детали подписки
Продолжение деталей подписки

Это массив JSON, каждый элемент которого является объектом, к которому вы можете обратиться индивидуально. Теперь нужно объединить все воедино.

Обратите внимание на pending_renewal_info. В настоящее время оно равно 1 или true. Если изменится на false в какой-то момент во время использования приложения, то пора сделать предложение об удержании.

Потратьте время, чтобы просмотреть чек и узнайте, сможете ли вы найти еще потенциальные возможности.

Примечание: поскольку мы используем изолированную среду, даты покупки и истечения срока действия одинаковы  —  ежемесячная подписка в нашем случае длится всего пять минут (для тестирования).

Это подводит меня к концу этой статьи  —  ну, кроме кода, конечно. Во-первых, полный код для классов IAPManager. Первая часть  —  это объект observable, который мы собираемся использовать в классе SwiftUI, а вторая часть  —  это шаблонные методы IAPManager.

import Foundation
import StoreKit

private let allTicketIdentifiers: Set<String> = [
  "ticket.consumable",
  "ticket.non_consumable",
  "ticket.subscription",
  "ticket.limited"
]


final class ProductsDB: ObservableObject, Identifiable {
  
  static let shared = ProductsDB()
  var items: [SKProduct] = [] {
      willSet {
        DispatchQueue.main.async {
          self.objectWillChange.send()
        }
      }
  }
}

class IAPManager: NSObject {
  static let shared = IAPManager()
  
  private override init() {
    super.init()
  }
  
  func getProducts() {
    let request = SKProductsRequest(productIdentifiers: allTicketIdentifiers)
    request.delegate = self
    request.start()
  }
  
  func purchase(product: SKProduct) -> Bool {
    if !IAPManager.shared.canMakePayments() {
        return false
    } else {
      let payment = SKPayment(product: product)
      SKPaymentQueue.default().add(payment)
    }
    return true
  }

  func canMakePayments() -> Bool {
    return SKPaymentQueue.canMakePayments()
  }
  
  func verifyReceipt() {
    let verifyURL = "https://sandbox.itunes.apple.com/verifyReceipt"
    
    guard let receiptURL = Bundle.main.appStoreReceiptURL, let receiptString = try? Data(contentsOf: receiptURL).base64EncodedString() , let url = URL(string: verifyURL) else {
          return
        }
                
    print("receiptURL ",receiptString)
    
    let requestData : [String : Any] = ["receipt-data" : receiptString,
                                            "password" : "214c835c2aca4b6d966704296bf25591",
                                            "exclude-old-transactions" : false]
    let httpBody = try? JSONSerialization.data(withJSONObject: requestData, options: [])
        
    var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = httpBody
        URLSession.shared.dataTask(with: request)  { (data, response, error) in
          // конвертация данных в Dictionary и просмотр покупок
          DispatchQueue.main.async {
            if let data = data, let jsonData = try? JSONSerialization.jsonObject(with: data, options: .allowFragments){
              print("jsonX ",jsonData)
              // нерасходуемые и невозобновляемые чеки о подписке находятся в массиве 'in_app'
              // автоматически возобновляемые чеки находятся в массиве latest_receipt_info
            }
          }
        }.resume()
  }
}

extension IAPManager: SKProductsRequestDelegate, SKRequestDelegate {

  func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
    let badProducts = response.invalidProductIdentifiers
    let goodProducts = response.products
    
    if !goodProducts.isEmpty {
      ProductsDB.shared.items = response.products
      print("bon ", ProductsDB.shared.items)
    }
    
    print("badProducts ", badProducts)
  }
  
  func request(_ request: SKRequest, didFailWithError error: Error) {
    print("didFailWithError ", error)
    DispatchQueue.main.async {
      print("purchase failed")
    }
  }
  
  func requestDidFinish(_ request: SKRequest) {
    DispatchQueue.main.async {
      print("request did finish ")
    }
  }
  
  func completeTransaction(_ transaction: SKPaymentTransaction) {
    print("transaction ",transaction)

  }
  
  func startObserving() {
    SKPaymentQueue.default().add(self)
  }
 
  func stopObserving() {
    SKPaymentQueue.default().remove(self)
  }
  
}


extension IAPManager: SKPaymentTransactionObserver {
  func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
     transactions.forEach { (transaction) in
      switch transaction.transactionState {
      case .purchased:
        SKPaymentQueue.default().finishTransaction(transaction)
        print("trans ",transaction)
        verifyReceipt()
//          verifyPurchaseWithPayment()
//        purchasePublisher.send(("Purchased ", true))
      case .restored:
//        totalRestoredPurchases += 1
        SKPaymentQueue.default().finishTransaction(transaction)
//        purchasePublisher.send(("Restored ", true))
      case .failed:
        if let error = transaction.error as? SKError {
//          purchasePublisher.send(("Payment Error \(error.code) ", false))
          print("Payment Failed \(error.code)")
        }
        SKPaymentQueue.default().finishTransaction(transaction)
      case .deferred:
        print("Ask Mom ...")
//        purchasePublisher.send(("Payment Diferred ", false))
      case .purchasing:
        print("working on it...")
//        purchasePublisher.send(("Payment in Process ", false))
      default:
        break
      }
    }
  }
}

extension String {
//: ### Кодирование Base64 в строку
    func base64Encoded() -> String? {
        if let data = self.data(using: .utf8) {
            return data.base64EncodedString()
        }
        return nil
    }

//: ### Декодирование Base64 в строку
    func base64Decoded() -> String? {
        if let data = Data(base64Encoded: self, options: .ignoreUnknownCharacters) {
            return String(data: data, encoding: .utf8)
        }
        return nil
    }
}

Это класс SwiftUI, который я объединил с файлом IAPManager.swift выше, чтобы создать простое демо-приложение подписки IAP.

import SwiftUI

struct ContentView: View {
    @State var purchased = false
    
    init() {
    }
    var body: some View {
          BuyView(purchased: $purchased)
    }
}
struct BuyView: View {
  @Binding var purchased: Bool
  @ObservedObject var products = ProductsDB.shared
  var body: some View {
      Text("Tickets")
        .onTapGesture {
          IAPManager.shared.getProducts()
          IAPManager.shared.startObserving()
        }
      List {
        ForEach((0 ..< self.products.items.count), id: \.self) { column in
          Text(self.products.items[column].localizedDescription)
            .onTapGesture {
              let _ = IAPManager.shared.purchase(product: self.products.items[column])
            }
        }
      }
  }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Для того, чтобы код заработал, придется создать продукты, которые можно купить с помощью iTunes Connect, в противном случае он ничего не найдет, и вы не увидите его в действии.

После запуска ничего не произойдет. Нажмите на вкладку Tickets, чтобы запросить поиск доступных покупок в приложении, а затем нажмите на один из элементов, которые вы хотите приобрести и который должен появиться в списке.

Спасибо за чтение!

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи Mark Lucking: Subscriptions, Receipts, and StoreKit in iOS 14

Предыдущая статьяПонятие о порталах в React с примерами использования
Следующая статьяИзбегаем исключения Null Pointer Exception в Java с помощью Optional