<?xml version="1.0" encoding="utf-8"?> 
<rss version="2.0"
  xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"
  xmlns:atom="http://www.w3.org/2005/Atom">

<channel>

<title>// by kei_sidorov: posts tagged storekit</title>
<link>https://sidorov.tech/tags/storekit/</link>
<description>Личный блог для статеек</description>
<author>Кирилл Сидоров</author>
<language>en</language>
<generator>E2 (v3576; Aegea)</generator>

<itunes:owner>
<itunes:name>Кирилл Сидоров</itunes:name>
<itunes:email></itunes:email>
</itunes:owner>
<itunes:subtitle>Личный блог для статеек</itunes:subtitle>
<itunes:image href="" />
<itunes:explicit></itunes:explicit>

<item>
<title>Определяем триал правильно</title>
<guid isPermaLink="false">9</guid>
<link>https://sidorov.tech/all/opredelyaem-trial-pravilno/</link>
<pubDate>Thu, 26 Jun 2025 22:46:02 +0000</pubDate>
<author>Кирилл Сидоров</author>
<comments>https://sidorov.tech/all/opredelyaem-trial-pravilno/</comments>
<description>
&lt;p&gt;Недавно в нашем приложении случился интересный кейс с in-app покупками — настолько неожиданный, что мы сначала решили, что это баг в StoreKit. А потом оказалось — всё работает ровно так, как должно. Просто знать об этом негде.&lt;/p&gt;
&lt;p&gt;Мы делаем приложение, которое помогает пользователям в США подготовиться к сдаче на права — на автомобиль, мотоцикл, коммерческий транспорт. В процессе они проходят серию экзаменов и марафонов вопросов, и после успешного завершения получают сертификат, который нужен для подачи документов в DMV (аналог ГАИ в Штатах). Мы предлагаем доступ к приложению по подписке — и в ней недавно начали эксперимент с триалом на 3 дня, чтобы улучшить конверсию.&lt;/p&gt;
&lt;p&gt;На эти три дня мы открываем доступ ко всему приложению, позволяем проходить все тесты, марафоны и экзамены, но выпуск сертификата доступен только после полной оплаты. Это осознанное решение: он имеет юридическую ценность, и давать его бесплатно мы не можем.&lt;/p&gt;
&lt;p&gt;И вот через неделю дней после запуска триала нам начали писать в поддержку: «Я оплатил подписку, а сертификат не доступен». Сначала мы долго копали в сторону синхронизации прогресса, потом и восе потерялись в догадках. Как всегда «у меня точно такая же рука и не болит». Мы смотрим — действительно, в чеке Apple оплата прошла, но в приложении числится триал, как так может быть? На этом месте мы начали думать, что StoreKit барахлит, или где-то подвис кеш. Но таких пользователей стало больше. И все они присылали реальные чеки с оплатой. Удивительно!&lt;/p&gt;
&lt;p&gt;Неделю копали и раскопали:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Пользователи действительно проходили все тесты за 1–2 дня — до окончания trial.&lt;/li&gt;
&lt;li&gt;Видели, что сертификат недоступен, и не хотели ждать.&lt;/li&gt;
&lt;li&gt;Шли в настройки приложения → Управление подписками → выбирали другую подписку из той же группы, но без триала — и оформляли её.&lt;/li&gt;
&lt;li&gt;У обеих подписок был один и тот же &lt;samp&gt;groupID&lt;/samp&gt;, и обе находились в &lt;samp&gt;currentEntitlements&lt;/samp&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Вот только одна — с trial, а вторая — платная. Мы не предполгагали что в один момент может существовать две транзакции и считали что при оформлении подписки в той же группе транзакция с триалом протузнет и будет только одна. Да чего уже там, мы вообще не предполагали что такая комбинация возможна — мы перестали предлагать опцию без триала на всех пейволах.&lt;/p&gt;
&lt;p&gt;То есть у нас в логике было примерно так (очень упрощенно):&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code class="swift"&gt;
extension Transaction {
    /// Триальная ли транзакция
    var isTrial: Bool {
        guard 
                let expirationDate = expirationDate, 
                Date() &lt; 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
        }
    }
}
&lt;/code&gt;
&lt;/pre&gt;
&lt;p&gt;&lt;br /&gt;&lt;/p&gt;
&lt;div class="callout"&gt;&lt;div class="pin"&gt;&lt;p&gt;☝🏼&lt;/p&gt;
&lt;/div&gt;&lt;p&gt;При смене одной подписки через настройки на другую (в той же группе) во время действия триала, появляется две активные транзакции: первая на время действия триала и вторая с оплаченной подпиской, с одинаковым &lt;samp&gt;originalTransactionId&lt;/samp&gt;.&lt;/p&gt;
&lt;/div&gt;&lt;p&gt;&lt;br /&gt;&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code class="swift"&gt;
extension Transaction {
    /// Триальная ли транзакция
    var isTrial: Bool {
        guard let expirationDate = expirationDate, Date() &lt; 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
        }
    }
}
&lt;/code&gt;
&lt;/pre&gt;
&lt;h3&gt;Решение&lt;/h3&gt;
&lt;p&gt;Решение очень простое — нужно искать последнюю транзакцию с одним &lt;samp&gt;originalID&lt;/samp&gt; и уже у неё проверять статус оффера.&lt;/p&gt;
&lt;pre class="e2-text-code"&gt;&lt;code class="swift"&gt;
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 &lt; latestTransaction.purchaseDate
                    {  continue }
                    latestTransactions[transaction.originalID] = transaction
                } catch { continue }
            }
            return latestTransactions.values.contains(where: { $0.isTrial })
        }
    }
}
&lt;/code&gt;
&lt;/pre&gt;
&lt;h3&gt;Вывод еще раз&lt;/h3&gt;
&lt;p&gt;StoreKit не инвалидирует первую триальную транзакцию из entitlements, даже если пользователь сменил подписку внутри группы. И это не баг, а особенность. Просто её надо учитывать, особенно если вы ограничиваете функциональность на trial-период.&lt;/p&gt;
&lt;p&gt;Заметка в духе «запиши, чтобы потом не искать». Надеюсь, кому-то сэкономит пару часов и несколько тикетов в поддержку.&lt;/p&gt;
&lt;p&gt;Буду рад вашей подписке на мой &lt;a href="https://t.me/sidorovtech"&gt;мой телеграм канал&lt;/a&gt;!&lt;/p&gt;
</description>
</item>


</channel>
</rss>