Определяем триал правильно
Недавно в нашем приложении случился интересный кейс с in-app покупками — настолько неожиданный, что мы сначала решили, что это баг в StoreKit. А потом оказалось — всё работает ровно так, как должно. Просто знать об этом негде.
Мы делаем приложение, которое помогает пользователям в США подготовиться к сдаче на права — на автомобиль, мотоцикл, коммерческий транспорт. В процессе они проходят серию экзаменов и марафонов вопросов, и после успешного завершения получают сертификат, который нужен для подачи документов в DMV (аналог ГАИ в Штатах). Мы предлагаем доступ к приложению по подписке — и в ней недавно начали эксперимент с триалом на 3 дня, чтобы улучшить конверсию.
На эти три дня мы открываем доступ ко всему приложению, позволяем проходить все тесты, марафоны и экзамены, но выпуск сертификата доступен только после полной оплаты. Это осознанное решение: он имеет юридическую ценность, и давать его бесплатно мы не можем.
И вот через неделю дней после запуска триала нам начали писать в поддержку: «Я оплатил подписку, а сертификат не доступен». Сначала мы долго копали в сторону синхронизации прогресса, потом и восе потерялись в догадках. Как всегда «у меня точно такая же рука и не болит». Мы смотрим — действительно, в чеке Apple оплата прошла, но в приложении числится триал, как так может быть? На этом месте мы начали думать, что StoreKit барахлит, или где-то подвис кеш. Но таких пользователей стало больше. И все они присылали реальные чеки с оплатой. Удивительно!
Неделю копали и раскопали:
- Пользователи действительно проходили все тесты за 1–2 дня — до окончания trial.
- Видели, что сертификат недоступен, и не хотели ждать.
- Шли в настройки приложения → Управление подписками → выбирали другую подписку из той же группы, но без триала — и оформляли её.
- У обеих подписок был один и тот же groupID, и обе находились в currentEntitlements.
Вот только одна — с trial, а вторая — платная. Мы не предполгагали что в один момент может существовать две транзакции и считали что при оформлении подписки в той же группе транзакция с триалом протузнет и будет только одна. Да чего уже там, мы вообще не предполагали что такая комбинация возможна — мы перестали предлагать опцию без триала на всех пейволах.
То есть у нас в логике было примерно так (очень упрощенно):
extension Transaction {
/// Триальная ли транзакция
var isTrial: Bool {
guard
let expirationDate = expirationDate,
Date() < expirationDate
else { return false }
return offer?.paymentMode == .freeTrial
}
/// Есть ли триальная транзакция у пользователя
static var hasTrialSubscription: Bool {
get async {
for await result in Transaction.currentEntitlements {
do {
if try result.payloadValue.isTrial {
return true
}
} catch { continue }
}
return false
}
}
}
☝🏼
При смене одной подписки через настройки на другую (в той же группе) во время действия триала, появляется две активные транзакции: первая на время действия триала и вторая с оплаченной подпиской, с одинаковым originalTransactionId.
extension Transaction {
/// Триальная ли транзакция
var isTrial: Bool {
guard let expirationDate = expirationDate, Date() < expirationDate else { return false }
return offer?.paymentMode == .freeTrial
}
/// Есть ли триальная транзакция у пользователя
static var hasTrialSubscription: Bool {
get async {
for await result in Transaction.currentEntitlements {
do {
if try result.payloadValue.isTrial {
return true
}
} catch { continue }
}
return false
}
}
}
Решение
Решение очень простое — нужно искать последнюю транзакцию с одним originalID и уже у неё проверять статус оффера.
extension Transaction {
/// Есть ли триальная транзакция у пользователя
static var hasTrialSubscription: Bool {
get async {
var latestTransactions: [UInt64: StoreKit.Transaction] = [:]
for await result in Transaction.currentEntitlements {
do {
let transaction = try result.payloadValue
if
let latestTransaction = latestTransactions[transaction.originalID],
transaction.purchaseDate < latestTransaction.purchaseDate
{ continue }
latestTransactions[transaction.originalID] = transaction
} catch { continue }
}
return latestTransactions.values.contains(where: { $0.isTrial })
}
}
}
Вывод еще раз
StoreKit не инвалидирует первую триальную транзакцию из entitlements, даже если пользователь сменил подписку внутри группы. И это не баг, а особенность. Просто её надо учитывать, особенно если вы ограничиваете функциональность на trial-период.
Заметка в духе «запиши, чтобы потом не искать». Надеюсь, кому-то сэкономит пару часов и несколько тикетов в поддержку.
Буду рад вашей подписке на мой мой телеграм канал!