Привет! Я Андрей, тимлид мобильной разработки в Adapty, я делаю SDK под iOS.
В нашей серии статей мы с командой рассказываем о внедрении покупок на iOS, и это шестая статья из серии. Познакомиться с остальными можно по ссылкам:
iOS in-app purchases, часть 1: Конфигурация и добавление в проект.
iOS in-app purchases, часть 2: Инициализация и обработка покупок.
iOS in-app purchases, часть 3: Серверная верификация покупок.
iOS in-app purchases, часть 4: Тестирование покупок.
iOS in-app purchases, часть 5: Обработка ошибок.
iOS in-app purchases, часть 6: Скидки для iOS in-apps purchases. — Вы тут.
Скидки помогают приложениям привлечь новых подписчиков и удержать тех, кто потерял интерес к продукту. В Apple довольно большой инструментарий для их реализации, и сегодня я расскажу, какие возможности можно использовать, чтобы предоставить пользователям скидку: introductory offers, promo offers, offer codes, и проч.
В статье я опишу, как работают все эти скидки, как их реализовать для кого их можно применять.
Что такое скидочное предложение (offer)?
Offer (в статье иногда буду называть его cкидочным предложением) — это снижение цены на подписки для пользователей. Есть несколько вариантов:
стартовое предложение — introductory offer;
промо предложение — promo offer — скидка уже подписанному пользователю, например, при переходе на более дорогую подписку
возможность активировать промокод — offer code, который гарантирует определенную фиксированную цену при покупке.
Варианты оплаты: free trial, pay as you go, pay upfront
Каждое скидочное предложение имеет один из доступных способов оплаты.
Free trial — бесплатное использование
В этом варианте подписчик может активировать подписку бесплатно на какое-то время. Деньги не будут списаны до конца периода, и пользователь может отменить подписку в любой момент. Пример: первый месяц бесплатного использования.
Pay as you go — оплата по мере использования
Подписчик платит сниженную цену каждый период подписки на протяжении какого-то времени. По истечении пробного периода списание происходит по обычной цене. Пример: три месяца со скидкой в 50%.
Pay up front — оплата наперёд
Пользователь единовременно оплачивает подписку на какой-то период. После окончания этого периода списание происходит по обычной цене. Пример: оплата сразу за год вперед со скидкой в 70%.
Стартовое предложение — introductory offer
Как работает introductory offer
Стартовое предложение позволяет пользователю ознакомиться с вашим приложением в течение какого-то фиксированного периода бесплатно или со скидкой, как упоминали выше: free trial, pay as you go, pay upfront.
Каждая подписка может иметь только одно стартовое предложение, которое в целом нельзя регулировать — Apple сам определяет, может ли пользователь применить эту скидку или нет. Чтобы на это влиять, есть более сложные сценарии, например, создать набор продуктов со скидкой и без и показывать разные продукты пользователю в зависимости от каких-то внутренних признаков, но это уже тема для другой статьи.
Реализация introductory offer
Для использования таких скидок нужно сначала настроить их в личном кабинете. Далее адаптировать интерфейс для отображения подобных скидок:
Шаг 1. Используя SKProductsRequest
, нужно получить список продуктов из магазина:
class PaywallViewController: UIViewController {
let productIds: Set<String> = ["<product_id>", "<product_id>"]
var productsRequest: SKProductsRequest?
override func viewDidLoad() {
super.viewDidLoad()
productsRequest = SKProductsRequest(productIdentifiers: productIds)
productsRequest?.delegate = self
productsRequest?.start()
}
}
extension PaywallViewController: SKProductsRequestDelegate {
func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
// your products are available here
// let’s take a very first one as an example
formatIntroductoryOfferMessage(for: response.products.first!)
}
func request(_ request: SKRequest, didFailWithError error: Error) {
// can’t fetch products
}
}
Шаг 2. Далее найти нужный продукт, внутри которого будет лежать поле introductoryPrice
, которое содержит всю информацию о вводном предложении, и сформировать цену скидки, ее длительность и проч. После отобразить это на экране оплаты:
Формирование локализованных заголовков для цены и длительности скидки
extension ViewController {
func formatIntroductoryOfferMessage(for product: SKProduct) {
let introductoryPrice = product.introductoryPrice
let locale = product.priceLocale
let localizedPrice = introductoryPrice?.price.localizedPrice(for: locale)
self.discountLabel.text = localizedPrice // ex.: $10 or $9.99
let localizedSubscriptionPeriod = introductoryPrice?.subscriptionPeriod.localizedPeriod(for: locale)
discountDurationLabel.text = localizedSubscriptionPeriod // ex.: 3 weeks
}
}
extension NSDecimalNumber {
func localizedPrice(for locale: Locale) -> String? {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.locale = locale
formatter.minimumFractionDigits = 0
formatter.maximumFractionDigits = 2
return formatter.string(from: self)
}
}
extension Locale {
func localizedComponents(day: Int? = nil, weekOfMonth: Int? = nil, month: Int? = nil, year: Int? = nil) -> String? {
// format and return localized period, like "3 weeks", "1 day", "2 years", etc
}
}
@available(iOS 11.2, macOS 10.13.2, *)
extension SKProductSubscriptionPeriod {
func localizedPeriod(for locale: Locale) -> String? {
switch unit {
case .day:
// apple treats 1 week as 7 days so far
if numberOfUnits == 7 { return locale.localizedComponents(weekOfMonth: 1) }
return locale.localizedComponents(day: numberOfUnits)
case .week:
return locale.localizedComponents(weekOfMonth: numberOfUnits)
case .month:
return locale.localizedComponents(month: numberOfUnits)
case .year:
return locale.localizedComponents(year: numberOfUnits)
@unknown default:
return nil
}
}
}
Покупка совершается в обычном режиме, разработчику ничего дополнительно указывать не требуется. Про покупки мы уже писали в нашей второй статье из серии про iOS.
Проверка доступности introductory offer
Чтобы проверить доступность вводного предложения для пользователя, нужно изучать его рецепт с покупками. Для этого необходимо провалидировать его на стороне сервера, а далее смотреть на признаки is_trial_period
и is_in_intro_offer_period
для всех покупок. Если хотя бы где-то эти флаги равны true
, то у пользователя нет права использовать вводное предложение в пределах этой подписки, а также для всех подписок из этой же группы. Для определения всех подписок группы можно использовать поле subscription_group_identifier
внутри responseBody.Pending_renewal_info
.
Определять доступность вводного предложения лучше всего на стороне сервера и как можно раньше, чтобы на этапе экрана оплаты уже принять решение, показывать пользователю скидку или нет.
Introductory offer доступен не для всех пользователей:
Для новых пользователей всегда доступно вводное предложение.
Для отписавшихся и не использовавших подобную скидку раньше также все доступно, но только при возобновлении подписки или активации любой другой подписки в пределах группы подписок.
Текущие подписчики не имеют права использовать вводное предложение для любого продукта в пределах существующей группы подписок, независимо от того, использовали ли они его в прошлом, или нет.
Промо предложение — promotional offer
Как работает promotional offer
Промо-предложение — это скидка или бесплатный доступ на определенный период. Такую скидку можно делать и тем, кто пользуется приложением сейчас, и тем, кто уже отписался. Обычно они используются для удержания существующей аудитории, чтобы продать более дорогой план или же вернуть отписавшихся пользователей.
Для каждой подписки можно завести не более десяти промо-предложений. Пользователи, активировавшие introductory offer, также могут использовать и promotional offer.
Решение, как и когда показывать промо предложение пользователю, ложится исключительно на плечи владельца приложения. Сценариев применения множество и они ничем не ограничиваются, например:
предложить скидку, как только пользователь не обновил подписку или отменил ее;
если пользователь уже несколько раз обновил месячную подписку, то можно предложить ему годовую подписку со скидкой.
Подготовка promotional offer и интеграции на стороне сервера
Добавление промо-предложений состоит из нескольких шагов, в частности:
проверка доступности промо-предложения;
показ промо на экране оплаты;
генерация подписи для совершения покупки с использованием промо;
покупка с использованием идентификатора промо.
Общая схема выглядит примерно так, как показано ниже. Пусть она вас не пугает, далее мы пройдемся по каждому ее аспекту и покажем на примерах как это выглядит на деле.
Для начала нужно сгенерировать приватный ключ, которым сервер будет подписывать все активируемые промо-предложения. Для этого нужно пойти в App Store connect → Users and Access → Keys → In-App Purchase → Generate In-App Purchase Key → Ввести желаемое имя (оно используется лишь для отображения в списке) → Generate. После генерации приватного ключа требуется скачать его. Для скачивания он будет доступен только один раз, так что лучше его не терять.
Далее нужно настроить сервер, чтобы он умел валидировать рецепты и слушать серверные уведомления App Store. Дополнительно нужно добавить функционал генерации подписи для промо предложений с использованием ключа из предыдущего пункта.
Следующим шагом нужно добавить желаемые предложения в дашборде App Store Connect. Для каждого из них требуется указать:
название (reference name), используется только для отображения в списке;
идентификатор предложения (promotional offer id), активно используется в приложении при определении того, какую скидку требуется применить;
метод предоставления скидки (offer type): free trial, pay as you go, pay upfront;
продолжительность;
цены, можно указывать разные цены для разных валют.
Клиентская реализация
Теперь можно приступить к реализации на стороне приложения.
Как и в случае с вводным предложением (introductory offer), нужно получить список продуктов из магазина, используя SKProductsRequest
. У каждого продукта внутри поля discounts лежат все сгенерированные промо предложения для каждой конкретной подписки.
Владелец приложения сам регулирует то, какое промо предложение применить в той или иной ситуации. Делается это посредством идентификатора скидки — он используется для поиска нужного элемента из общего списка.
extension PaywallViewController: SKProductsRequestDelegate {
func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
// your products are available here
// let’s take a very first one as an example
print("promo offers list: \(response.products.first!.discounts)")
let myOffer = response.products.first?.discounts.filter { $0.identifier == "my_offer" }.first
}
}
Адаптация платежного экрана, чтобы вывести на нем информацию о промо-предложении, точно такая же, как и в случае с вводным предложением. Т.к. модель сущности SKProductDiscount
используется та же самая, можно с легкостью переиспользовать код, который был написан для обработки introductory offer. Требуется лишь дополнительно передавать идентификатор предложения, который нужно применить:
extension ViewController {
func formatPromoOfferMessage(for product: SKProduct, offerId: String) {
let promoOffer = product.discounts.filter { $0.identifier == offerId }.first
let locale = product.priceLocale
let localizedPrice = promoOffer?.price.localizedPrice(for: locale)
self.discountLabel.text = localizedPrice // ex.: $10 or $9.99
let localizedSubscriptionPeriod = promoOffer?.subscriptionPeriod.localizedPeriod(for: locale)
discountDurationLabel.text = localizedSubscriptionPeriod // ex.: 3 weeks
}
}
При покупке требуется явно указывать идентификатор предложения, который нужно применить, а также нужно получить сгенерированную подпись и еще ряд параметров для промо предложения с сервера. Далее передать это все в объект SKPaymentDiscount
и уже этот объект скидки положить в поле paymentDiscount
в покупку.
@available(iOS 12.2, *)
private func createPayment(from product: SKProduct, offerId: String, completion: BuyProductCompletion? = nil) {
// generate signing for promo offer
ApiManager.signSubscriptionOffer(params: ["product": product.productIdentifier, "offer_id": offerId]) { (params, error) in
guard error == nil else {
// response error
completion?(nil, nil, nil, product, error)
return
}
guard
let keyIdentifier = params?["key_id"] as? String,
let nonceString = params?["nonce"] as? String,
let nonce = UUID(uuidString: nonceString),
let signature = params?["signature"] as? String,
let timestampString = params?["timestamp"] as? String,
let timestampInt64 = Int64(timestampString)
else {
// missing some of the offer params
completion?(nil, nil, nil, product, Error.missingOfferSigningParams)
return
}
let timestamp = NSNumber(value: timestampInt64)
let payment = SKMutablePayment(product: product)
payment.applicationUsername = ""
payment.paymentDiscount = SKPaymentDiscount(identifier: offerId, keyIdentifier: keyIdentifier, nonce: nonce, signature: signature, timestamp: timestamp)
SKPaymentQueue.default().add(payment)
}
}
При желании можно узнать, какое промо предложение было применено к конкретной покупке. Для этого надо посмотреть успешную транзакцию SKPaymentTransaction
. Внутри нее есть поле paymentDiscount
, где лежит идентификатор примененного промо.
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
transactions.forEach { (transaction) in
switch transaction.transactionState {
case .purchased:
print(transaction.payment.paymentDiscount?.identifier)
...
}
}
}
Проверка доступности promo offer
Проверка доступности промо-предложения для пользователя базируется на двух идеях.
App Store считает, что у всех, у кого есть действующая или истекшая подписка, по умолчанию есть доступ к промо-предложению. Для понимания того, что у пользователя есть или была подписка, достаточно провалидировать и изучить его рецепт.
Далее нужно уже самостоятельно определять любые дополнительные критерии доступности конкретного предложения. Право на применение может зависеть от широкого спектра логики в зависимости от потребностей бизнеса.
Один из распространенных способов применения промо-предложений — через серверное уведомление DID_CHANGE_RENEWAL_STATUS
, которое отправляется, когда пользователь меняет тип автопродления подписки. Например, когда пользователь отключает автопродление, можно показать ему промо-предложение, чтобы мотивировать возобновить подписку.
Также стоит учитывать, что промо-предложения доступны только на iOS 12.2 и выше, macOS 10.14.4 и выше, tvOS 12.2 и выше, поэтому нужно проверять доступность устройства и предлагать пользователю обновить OS, если он хочет применить промо предложение на неподдерживаемой версии операционной системы.
Промокоды — offer codes
Как работают offer codes
Промокоды — это строка из букв и цифр, которая даёт скидку на подписку или бесплатный триал. Промокоды стали доступны с iOS 14 и iPadOS 14, когда их анонсировали Apple. Распространять их можно как онлайн, так и оффлайн, например:
при отправке рассылки с новыми функциями приложения можно приложить промокод на бесплатный период, чтобы заинтересовать пользователя;
раздача листовок на улице с промокодом на скидку на первую покупку внутри приложения;
партнерские программы с другими компаниями или блогерами, которые предоставляют эксклюзивный промокод на индивидуальную скидку.
Реализация offer codes
Для начала нужно сгенерировать эти промокоды в пределах какой-то подписки. Делается это через App Store Connect.
Пользователь может активировать код любым из трех способов:
Через App Store. Для этого нужно открыть App Store и в разделе с приложениями найти соответствующую форму ввода кода.
По специальной ссылке из дашборда App Store Connect. Этот сценарий не отличается от первого, разве что ссылку проще напрямую отправить аудитории и не придется искать заветного окна ввода в приложении App Store, достаточно будет кликнуть на ссылку и ввести код.
Напрямую в приложении. В случае активации через приложение надо вызвать соответствующий метод, который покажет окно ввода кода:
SKPaymentQueue.default().presentCodeRedemptionSheet()
Когда пользователь активирует промокод, в приложение приходит уведомление с транзакцией в SKPaymentQueue
, а также на сервер попадает уведомление в случае, если были настроены соответствующие серверные уведомления.
extension YourClass: SKPaymentTransactionObserver {
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
transactions.forEach { (transaction) in
switch transaction.transactionState {
case .purchased:
// handle successful purchase
case .failed:
// handle error
case .restored:
// handle restore
case .deferred, .purchasing: break
@unknown default: break
}
}
}
}
В итоге после покупки надо получить уведомление с транзакцией в SKPaymentQueue
, взять актуальный рецепт и отправить этот рецепт на валидацию на сервер. Уже сервер должен понять, что был применен промокод.
Для определения активированного промокода требуется проверять рецепт на наличие транзакции с полем offer_code_ref_name
. Это поле содержит название скидочного предложения, которое было создано в App Store Connect. Искать его следует в responseBody.Latest_receipt_info
и responseBody.Pending_renewal_info
внутри рецепта, но для серверных уведомлений аналогичные поля называются уже по-другому – unified_receipt.Latest_receipt_info
и unified_receipt.Pending_renewal_info
.
Надо также учесть, что промокод мог быть активирован еще в магазине, перед установкой приложения. В этом случае, во время запуска надо валидировать рецепт, чтобы сразу выдать пользователю доступ к контенту на старте.
Ограничения
Можно сгенерировать только 10 скидочных предложений, для которых есть лимит в 150 000 промокодов за квартал на приложение, так что используйте их с умом. Срок жизни промокодов — шесть месяцев. Тестировать промокоды можно только на живой сборке из App Store, Sandbox для подобных вещей пока не предусмотрен.
Когда Apple ввели запрет на общий доступ к IDFA, некоторые пытались использовать промо-коды, чтобы атрибуцировать пользователей, но ограничение в 150 тысяч кодов свели все эти старания на нет.
В целом добавление скидочных предложений в приложение не является очень сложной механикой, но потенциально может привлечь дополнительную прибыль.
Про Adapty
Adapty SDK упрощает подключение покупок в приложениях, поддерживает все актуальные фичи магазинов, как те, которые мы описали в этой статье, и делает серверную валидацию платежей. Но это не все преимущества:
Встроенная аналитика позволяет легко понимать главные метрики приложения.
Когортный анализ показывает, сходится ли экономика.
А/Б тесты пейволлов делаются налету и помогают увеличивать конверсию в платных пользователей.
Интеграции с внешними системами позволяют отправлять транзакции в сервисы атрибуции и продуктовой аналитики.
Промо кампании уменьшают отток аудитории.
Open source SDK позволяет интегрировать подписки в приложение за несколько часов.
Серверная валидация и API для работы с другими платформами упрощает работу с покупками.
Познакомьтесь подробнее с этими возможностями, чтобы быстрее внедрить подписки в своё приложение и улучшить конверсии.