Руководство по реализации авто-возобновляемых подписок в iOS-приложениях

image

Всем привет! Меня зовут Денис, я разрабатываю Apphud — сервис по аналитике авто-возобновляемых подписок в iOS-приложениях.

В данной статье я расскажу как настроить, реализовать и валидировать авто-возобновляемые подписки в iOS 12 и iOS 13. Бонусом расскажу о тонких моментах и подводных камнях, которые не все разработчики учитывают.


Настройка подписок в App Store Connect

Если у вас уже есть Bundle ID и созданное приложение, то вы можете пропустить эти шаги. Если же вы создаете приложение впервые, то сделайте следующее:

На портале разработчиков Apple вы должны создать явный Bundle ID (App ID). Открыв страницу, которая называется Certificates, Identifiers & Profiles, перейдите во вкладку Identifiers. В июне 2019 года Apple, наконец, обновила верстку портала в соответствии с ASC (сокращенно от App Store Connect).

Новый дизайн портала разработчиков Apple в 2019 году
Новый дизайн портала разработчиков Apple в 2019 году

cbb84c1eac492c0c10e18aa373272c49.png

Явный Bundle ID принято указывать в доменном стиле (com.apphud.subscriptionstest). В разделе Capabilities вы заметите, что галочка рядом с In App Purchases уже стоит. Создав Bundle ID (App ID), перейдите в App Store Connect.


Тестовые пользователи (Sandbox users)

Для тестирования будущих покупок вам нужно будет создать тестового пользователя. Для этого перейдите в ASC во вкладку Пользователи и Доступ, далее в Тестировщики Sandbox.

Форма создания Sandbox пользователя
Форма создания Sandbox пользователя


При создании тестировщика можно указать любые несуществующие данные, главное, не забыть потом email и пароль!

О том, как тестировать покупки с тестовыми учетными данными я расскажу ближе к концу статьи.

Еще один важный шаг — это настройка контрактов и банковских данных в разделе »Соглашения, налоги и банковские операции». Если у вас не будет настроено соглашение для платных приложений, то вы не сможете тестировать авто-возобновляемые подписки!

После этого можно создать новое приложение в App Store Connect. Укажите уникальное имя и выберите в качестве ID пакета ваш Bundle ID.

ID пакета — это ваш Bundle ID
ID пакета — это ваш Bundle ID

Сразу после создания приложения перейдите во вкладку Функции.


Если у вас уже было создано приложение, то можете продолжить читать отсюда.

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

1. Создание идентификатора подписки и создание группы подписок. Группа подписок представляет собой коллекцию подписок с разными периодами и ценами, но которые открывают один и тот же функционал в приложении. Так же в группе подписок можно лишь однажды активировать бесплатный пробный период и лишь одна из подписок может быть активной. Если вы хотите, чтобы в вашем приложении было одновременно две разные подписки, то вам нужно будет создать две группы подписок.

2. Заполнение данных подписки: длительность, отображаемое название в App Store (не путать с просто названием) и описание. В случае добавления первой подписки в группу необходимо будет указать отображаемое название группы подписок. Не забудьте почаще сохранять изменения, ASC может зависнуть в любой момент и перестать отвечать на запросы.

Страница подписки
Экран подписки

3. Заполнение цены подписки. Тут есть два этапа: создание цены и специальных предложений. Укажите реальную цену в любой валюте, она автоматически пересчитается для всех других стран. Вводные предложения: тут вы можете предложить пользователям бесплатный пробный период либо скидки по предоплате. Промопредложения появились в App Store совсем недавно в 2019 году: они позволяют предлагать особые скидки пользователям, которые отменили подписку и которых вы хотите вернуть.


Генерация секретного общего ключа (shared secret key)

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

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

В данном примере создано три группы подписок и 3 годовые подписки
В этом примере создано три группы подписок и 3 годовые подписки

Скопируйте ID всех ваших подписок и общий ключ, это пригодится в дальнейшем в коде.


Программная часть

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


  1. Оформление покупок


  2. Проверка статуса подписки


  3. Обновление чека


  4. Восстановление транзакций (не путать с обновлением чека!)



Оформление покупок

Весь процесс оформления покупки можно разделить на 2 этапа: получение продуктов (класс SKProduct) и инициализация процесса покупки (класс SKPayment). В первую очередь мы должны указать делегат протокола SKPaymentTransactionObserver.

// Starts products loading and sets transaction observer delegate
@objc func startWith(arrayOfIds : Set!, 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)
                        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) {
        // 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] 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:

Таблица различий двух способов восстановления покупок из WWDC
Таблица различий двух способов восстановления покупок из WWDC

В большинстве случаев вам достаточно использовать SKReceiptRefreshRequest(), потому что нас интересует только получение чека для последующего вычисления даты истечения.

В случае авто-возобновляемых подписок сами транзакции нас не интересуют, поэтому достаточно использовать только обновление чека. Однако есть случаи, когда нужно использовать способ восстановления транзакций: если ваше приложение скачивает контент при покупке (Apple hosted content) или если вы до сих пор поддерживаете версии ниже iOS 7.


Тестирование покупок (Sandbox Testing)

Раньше для тестирования покупок необходимо было логаутиться из App Store в настройках вашего iPhone. Это доставляло большие неудобства (например, стиралась вся медиатека Apple Music). Однако сейчас этого делать не нужно: аккаунт песочницы теперь существует отдельно от основного аккаунта.

846b0d1d5a265e9a3f38ebeaa1bd61ef.jpg

Процесс покупки происходит схожим образом в сравнении с реальными покупками в App Store, но есть некоторые моменты:


  • Вам всегда нужно будет вводить логин-пароль через системное окно. Покупки с использованием Touch ID/Face ID до сих пор не поддерживаются.


  • Если при верном вводе логина и пароля система снова и снова запрашивает логин-пароль, нажмите «Отменить», сверните приложение, затем попробуйте снова. Выглядит как бред, но у многих срабатывает. Но иногда после второго ввода пароля процесс все же идет дальше.


  • Вы никак не сможете протестировать процесс отмены подписки.


  • Длительности периодов подписок значительно ниже реальных. И обновляются они не более 6 раз в день.



Что нового в StoreKit в iOS 13?

Из нового — только класс SKStorefront, который дает информацию о том, в какой именно стране в App Store зарегистрирован данный пользователь. Это может быть полезно тем разработчикам, которые используют разные подписки для разных стран. Раньше все проверяли по геолокации либо по региону устройства, но это не давало точный результат. Теперь страну в App Store узнать очень просто: SKPaymentQueue.default().storefront?.countryCode. Так же добавился делегат метода, если во время процесса покупки сменилась страна в App Store. В этом случае можно самим продолжить или отменить процесс покупки.


Подводные камни при работе с подписками


  • Проверка чека прямиком с устройства не рекомендуется Apple. Они несколько раз говорили об этом на WWDC (с 5:50) и это указано в документации. Это небезопасно, потому что злоумышленник может перехватить данные с помощью man-in-the-middle атаки. Правильный способ проверки чеков — это локальная валидация либо используя свой сервер.
  • Существует проблема, связанная с проверкой даты истечения. Если вы не используете свой сервер, то системное время на устройстве можно изменить на более старое и тогда наш код будет выдавать неверный результат — подписка будет считаться активной. Если вас это не устраивает, то можете использовать любой сервис, выдающий точное мировое время.
  • Не для всех пользователей бесплатный пробный период может быть доступен. Пользователь мог заново установить приложение через некоторое время, а приложение покажет, что триал доступен как обычно. Правильно будет обновлять чек, валидировать его и проверять в JSON доступность триала для данного пользователя. Многие этого не делают.
  • Если пользователь запросил возврат средств, то в JSON подписки добавится cancellation_date, но expires_date останется неизменным. Поэтому важно всегда проверять наличие поля cancellation_date, которое является преимущественным по отношению к expires_date.
  • Не стоит обновлять чек при каждом запуске приложения, потому что, во-первых, это бессмысленно, во-вторых, скорее всего пользователю покажется окно ввода пароля от Apple ID. Обновлять чек стоит, например, когда пользователь сам нажал на кнопку восстановления покупок.
  • Как определить, в какие моменты стоит валидировать чек для получения актуальной даты истечения подписки? Можно валидировать чек при каждом запуске либо только при истечении подписки. Однако если вы будете проверять чек только при истечении подписки, пользователь, оформивший возврат средств, сможет бесплатно пользоваться вашим приложением до конца периода.


Заключение

Надеюсь, данная статья вам будет полезна. Я попытался добавить не только код, но и объяснить тонкие моменты при разработке. Полный код класса можете скачать здесь. Этот класс будет очень полезен для ознакомления начинающим разработчикам и тем, кто хочет подробнее узнать, как все устроено. Для живых приложений рекомендуется использовать более серьезные решения, например, SwiftyStoreKit.

© Habrahabr.ru