Если у вас уже есть Bundle ID и созданное приложение, то вы можете пропустить эти шаги. Если же вы создаете приложение впервые, то сделайте следующее:
На портале разработчика Apple вы должны создать явный Bundle ID (App ID). Открыв страницу, которая называется Certificates, Identifiers & Profiles, перейдите во вкладку Identifiers. В июне 2019 года Apple, наконец, обновила верстку портала в соответствии с ASC (сокращенно от App Store Connect)
Явный Bundle ID принято указывать в доменном стиле (com.apphud.subscriptionstest
). В разделе Capabilities вы заметите, что галочка рядом с In App Purchases уже стоит. Создав Bundle ID (App ID), перейдите в App Store Connect.
Для тестирования будущих покупок вам нужно будет создать тестового пользователя. Для этого перейдите в ASC во вкладку Пользователи и Доступ, далее в Тестировщики Sandbox.
О том, как тестировать покупки с тестовыми учетными данными я расскажу ближе к концу статьи.
Еще один важный шаг — это настройка контрактов и банковских данных в разделе “Соглашения, налоги и банковские операции”. Если у вас не будет настроено соглашение для платных приложений, то вы не сможете тестировать авто-возобновляемые подписки!
После этого можно создать новое приложение в App Store Connect. Укажите уникальное имя и выберите в качестве ID пакета ваш Bundle ID.
Сразу после создания приложения перейдите во вкладку Функции.
Процесс создания авто-возобновляемой подписки состоит из нескольких этапов:
Создание идентификатора подписки и создание группы подписок. Группа подписок представляет собой коллекцию подписок с разными периодами и ценами, но которые открывают один и тот же функционал в приложении. Так же в группе подписок можно лишь однажды активировать бесплатный пробный период и лишь одна из подписок может быть активной. Если вы хотите, чтобы в вашем приложении было одновременно две разные подписки, то вам нужно будет создать две группы подписок.
Более подробно о подписках и группах подписок вы можете почитать в другой нашей статье.
Заполнение данных подписки: длительность, отображаемое название в App Store (не путать с просто названием) и описание. В случае добавления первой подписки в группу необходимо будет указать отображаемое название группы подписок. Не забудьте почаще сохранять изменения, ASC может зависнуть в любой момент и перестать отвечать на запросы.
Заполнение цены подписки. Тут есть два этапа: создание цены и специальных предложений. Укажите реальную цену в любой валюте, она автоматически пересчитается для всех других стран. Вводные предложения: тут вы можете предложить пользователям бесплатный пробный период либо скидки по предоплате. Промо-предложения появились в App Store совсем недавно в 2019 году: они позволяют предлагать особые скидки пользователям, которые отменили подписку и которых вы хотите вернуть.
На странице со списком всех ваших созданных подписок вы увидите кнопку Общий ключ для приложения. Это специальная строка, которая нужна для валидирования чека в iOS приложении. Валидировать чек нам нужно будет для определения статуса подписки.
Общий ключ может быть двух видов: уникальный ключ для вашего приложения или единый ключ для вашего аккаунта. Важно: ни в коем случае не пересоздавайте ключ, если у вас уже есть приложение в App Store, иначе пользователи не смогут получить валидировать чек и ваше приложение перестанет работать как положено.
Скопируйте ID всех ваших подписок и общий ключ, это пригодится в дальнейшем в коде.
Приступим к практической части. Что нужно для того, чтобы сделать полноценный менеджер покупок? Должно быть реализовано как минимум следующее:
Весь процесс оформления покупки можно разделить на 2 этапа: получение продуктов (класс SKProduct
) и инициализация процесса покупки (класс SKPayment
). В первую очередь мы должны указать делегат протокола SKPaymentTransactionObserver
.
// Starts products loading and sets transaction observer delegate @objc func startWith(arrayOfIds : Set<String>!, sharedSecret : String){ SKPaymentQueue.default().add(self) self.sharedSecret = sharedSecret self.productIds = arrayOfIds loadProducts() } private func loadProducts(){ let request = SKProductsRequest.init(productIdentifiers: productIds) request.delegate = self request.start() } public func productsRequest(\_ request: SKProductsRequest, didReceive response: SKProductsResponse) { products = response.products DispatchQueue.main.async { NotificationCenter.default.post(name: IAP\_PRODUCTS\_DID\_LOAD\_NOTIFICATION, object: nil) } } func request(\_ request: SKRequest, didFailWithError error: Error){ print("error: \\(error.localizedDescription)") }
Полный код доступен по ссылке ниже
Уведомление IAP_PRODUCTS_DID_LOAD_NOTIFICATION
используется для того, чтобы обновить UI в приложении.
Далее пишем метод для инициализации покупки:
func purchaseProduct(product : SKProduct, success: @escaping SuccessBlock, failure: @escaping FailureBlock){ guard SKPaymentQueue.canMakePayments() else { return } guard SKPaymentQueue.default().transactions.last?.transactionState != .purchasing else { return } self.successBlock = success self.failureBlock = failure let payment = SKPayment(product: product) SKPaymentQueue.default().add(payment) }
Делегат SKPaymentTransactionObserver
выглядит так:
extension IAPManager: SKPaymentTransactionObserver { public func paymentQueue(\_ queue: SKPaymentQueue, updatedTransactions transactions: \[SKPaymentTransaction\]) { for transaction in transactions { switch (transaction.transactionState) { case .purchased: SKPaymentQueue.default().finishTransaction(transaction) notifyIsPurchased(transaction: transaction) break case .failed: SKPaymentQueue.default().finishTransaction(transaction) print("purchase error : \\(transaction.error?.localizedDescription ?? "")") self.failureBlock?(transaction.error) cleanUp() break case .restored: SKPaymentQueue.default().finishTransaction(transaction) notifyIsPurchased(transaction: transaction) break case .deferred, .purchasing: break default: break } } } private func notifyIsPurchased(transaction: SKPaymentTransaction) { refreshSubscriptionsStatus(callback: { self.successBlock?() self.cleanUp() }) { (error) in // couldn't verify receipt self.failureBlock?(error) self.cleanUp() } } func cleanUp(){ self.successBlock = nil self.failureBlock = nil } }
При успешном оформлении подписки вызывается метод делегата, в котором транзакция имеет состояние purchased
.
Но как определить дату истечения подписки? Для этого нужно сделать отдельный запрос в Apple.
Чек валидируется с помощью POST-запроса verifyReceipt
к Apple, в качестве параметра посылаем зашифрованный чек в виде base64-закодированной строки, а в ответе нам приходит тот же чек в JSON формате. В массиве по ключу latest_receipt_info
будут перечислены все транзакции от каждого периода каждой подписки, включая пробные периоды. Нам остается только спарсить ответ и достать актуальную дату истечения для каждого продукта.
На WWDC 2017 добавили возможность получать только актуальные чеки по каждой подписке с помощью ключа exclude-old-transactions
в запросе verifyReceipt
func refreshSubscriptionsStatus(callback : @escaping SuccessBlock, failure : @escaping FailureBlock){ // save blocks for further use self.refreshSubscriptionSuccessBlock = callback self.refreshSubscriptionFailureBlock = failure guard let receiptUrl = Bundle.main.appStoreReceiptURL else { refreshReceipt() // do not call block yet return } #if DEBUG let urlString = "https://sandbox.itunes.apple.com/verifyReceipt" #else let urlString = "https://buy.itunes.apple.com/verifyReceipt" #endif let receiptData = try? Data(contentsOf: receiptUrl).base64EncodedString() let requestData = \["receipt-data" : receiptData ?? "", "password" : self.sharedSecret, "exclude-old-transactions" : true\] as \[String : Any\] var request = URLRequest(url: URL(string: urlString)!) request.httpMethod = "POST" request.setValue("Application/json", forHTTPHeaderField: "Content-Type") let httpBody = try? JSONSerialization.data(withJSONObject: requestData, options: \[\]) request.httpBody = httpBody URLSession.shared.dataTask(with: request) { (data, response, error) in DispatchQueue.main.async { if data != nil { if let json = try? JSONSerialization.jsonObject(with: data!, options: .allowFragments){ self.parseReceipt(json as! Dictionary<String, Any>) return } } else { print("error validating receipt: \\(error?.localizedDescription ?? "")") } self.refreshSubscriptionFailureBlock?(error) self.cleanUpRefeshReceiptBlocks() } }.resume() }
В начале метода вы можете видеть, что идет проверка на существование локальной копии чека. Локальный чек может и не существовать, например, если приложение было установлено через iTunes. При отсутствии чека мы не можем выполнить запросverifyReceipt
. Сначала нам нужно получить актуальный локальный чек, а затем снова попытаться его валидировать. Обновление чека делается с помощью класса SKReceiptRefreshRequest
:
private func refreshReceipt(){ let request = SKReceiptRefreshRequest(receiptProperties: nil) request.delegate = self request.start() } func requestDidFinish(\_ request: SKRequest) { // call refresh subscriptions method again with same blocks if request is SKReceiptRefreshRequest { refreshSubscriptionsStatus(callback: self.successBlock ?? {}, failure: self.failureBlock ?? {\_ in}) } } func request(\_ request: SKRequest, didFailWithError error: Error){ if request is SKReceiptRefreshRequest { self.refreshSubscriptionFailureBlock?(error) self.cleanUpRefeshReceiptBlocks() } print("error: \\(error.localizedDescription)") }
Обновление чека реализовано в функции refreshReceipt()
. Если чек успешно обновился, то вызовется метод делегата requestDidFinish(_ request : SKRequest)
, которая повторно вызывает метод refreshSubscriptionsStatus
.
Как реализован парсинг информации о покупках? Нам возвращается JSON объект, в котором есть вложенный массив транзакций (по ключу latest_receipt_info
). Проходимся по массиву, достаем дату истечения по ключу expires_date
и сохраняем ее, если эта дата еще не наступила.
private func parseReceipt(\_ json : Dictionary<String, Any>) { // It's the most simple way to get latest expiration date. Consider this code as for learning purposes. Do not use current code in production apps. guard let receipts\_array = json\["latest\_receipt\_info"\] as? \[Dictionary<String, Any>\] else { self.refreshSubscriptionFailureBlock?(nil) self.cleanUpRefeshReceiptBlocks() return } for receipt in receipts\_array { let productID = receipt\["product\_id"\] as! String let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd HH:mm:ss VV" if let date = formatter.date(from: receipt\["expires\_date"\] as! String) { if date > Date() { // do not save expired date to user defaults to avoid overwriting with expired date UserDefaults.standard.set(date, forKey: productID) } } } self.refreshSubscriptionSuccessBlock?() self.cleanUpRefeshReceiptBlocks() }
Я привел простейший пример, как извлечь актуальную дату истечения подписки. Тут нет обработки ошибок и, например, нет проверки на возврат покупки (добавляется cancellation date
).
Чтобы определить, активна подписка или нет, достаточно сравнить текущую дату с датой из User Defaults по ключу продукта. Если же она отсутствует или меньше текущей даты, то подписка считается неактивной.
func expirationDateFor(\_ identifier : String) -> Date?{ return UserDefaults.standard.object(forKey: identifier) as? Date } let subscriptionDate = IAPManager.shared.expirationDateFor("YOUR\_PRODUCT\_ID") ?? Date() let isActive = subscriptionDate > Date()
Восстановление транзакций выполняется одной строчкой SKPaymentQueue.default().restoreCompletedTransactions()
. Эта функция восстанавливает все завершенные транзакции, снова вызывая метод делегата func paymentQueue(**_** queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction])
.
Оба метода помогают восстановить данные о покупках. Но в чем же их отличия? Есть замечательная таблица с видео wwdc:
В большинстве случаев вам достаточно использовать SKReceiptRefreshRequest()
, потому что нас интересует только получение чека для последующего вычисления даты истечения.
В случае авто-возобновляемых подписок сами транзакции нас не интересуют, поэтому достаточно использовать только обновление чека. Однако есть случаи, когда нужно использовать способ восстановления транзакций: если ваше приложение скачивает контент при покупке (Apple hosted content) или если вы до сих пор поддерживаете версии ниже iOS 7.
Раньше для тестирования покупок необходимо было выходить из App Store в настройках вашего iPhone. Это доставляло большие неудобства (например, стиралась вся медиатека Apple Music). Однако сейчас этого делать не нужно: аккаунт песочницы теперь существует отдельно от основного аккаунта.
Процесс покупки происходит схожим образом в сравнении с реальными покупками в App Store, но есть некоторые моменты:
Из нового только класс SKStorefront
, который дает информацию о том, в какой именно стране в App Store зарегистрирован данный пользователь. Это может быть полезно тем разработчикам, которые используют разные подписки для разных стран. Раньше все проверяли по геолокации либо по региону устройства, но это не давало точный результат. Теперь страну в App Store узнать очень просто: SKPaymentQueue.default().storefront?.countryCode
. Так же добавился делегат метода, если во время процесса покупки сменилась страна в App Store. В этом случае можно самим продолжить или отменить процесс покупки.
cancellation_date
, но expires_date
останется неизменным. Поэтому важно всегда проверять наличие поля cancellation_date
, которое является преимущественным по отношению к expires_date
.Надеюсь, данная статья вам будет полезна. Я попытался добавить не только код, но и объяснить тонкие моменты при разработке. Полный код класса можете скачать здесь. Этот класс будет очень полезен для ознакомления начинающим разработчикам и тем, кто хочет подробнее узнать, как все устроено. Для живых приложений рекомендуется использовать более серьезные решения, например, Apphud – сервис для точного анализа и интеграции встроенных подписок в вашем приложении, с возможность возврата ушедших подписчиков.