{
    "version": "https:\/\/jsonfeed.org\/version\/1",
    "title": "\/\/ by kei_sidorov: posts tagged In-App Purchases",
    "_rss_description": "Личный блог для статеек",
    "_rss_language": "en",
    "_itunes_email": "",
    "_itunes_categories_xml": "",
    "_itunes_image": "",
    "_itunes_explicit": "",
    "home_page_url": "https:\/\/sidorov.tech\/tags\/in-app-purchases\/",
    "feed_url": "https:\/\/sidorov.tech\/tags\/in-app-purchases\/json\/",
    "icon": "https:\/\/sidorov.tech\/user\/userpic@2x.jpg?1729508804",
    "author": {
        "name": "Кирилл Сидоров",
        "url": "https:\/\/sidorov.tech\/",
        "avatar": "https:\/\/sidorov.tech\/user\/userpic@2x.jpg?1729508804"
    },
    "items": [
        {
            "id": "9",
            "url": "https:\/\/sidorov.tech\/all\/opredelyaem-trial-pravilno\/",
            "title": "Определяем триал правильно",
            "content_html": "<p>Недавно в нашем приложении случился интересный кейс с in-app покупками — настолько неожиданный, что мы сначала решили, что это баг в StoreKit. А потом оказалось — всё работает ровно так, как должно. Просто знать об этом негде.<\/p>\n<p>Мы делаем приложение, которое помогает пользователям в США подготовиться к сдаче на права — на автомобиль, мотоцикл, коммерческий транспорт. В процессе они проходят серию экзаменов и марафонов вопросов, и после успешного завершения получают сертификат, который нужен для подачи документов в DMV (аналог ГАИ в Штатах). Мы предлагаем доступ к приложению по подписке — и в ней недавно начали эксперимент с триалом на 3 дня, чтобы улучшить конверсию.<\/p>\n<p>На эти три дня мы открываем доступ ко всему приложению, позволяем проходить все тесты, марафоны и экзамены, но выпуск сертификата доступен только после полной оплаты. Это осознанное решение: он имеет юридическую ценность, и давать его бесплатно мы не можем.<\/p>\n<p>И вот через неделю дней после запуска триала нам начали писать в поддержку: «Я оплатил подписку, а сертификат не доступен». Сначала мы долго копали в сторону синхронизации прогресса, потом и восе потерялись в догадках. Как всегда «у меня точно такая же рука и не болит». Мы смотрим — действительно, в чеке Apple оплата прошла, но в приложении числится триал, как так может быть? На этом месте мы начали думать, что StoreKit барахлит, или где-то подвис кеш. Но таких пользователей стало больше. И все они присылали реальные чеки с оплатой. Удивительно!<\/p>\n<p>Неделю копали и раскопали:<\/p>\n<ul>\n<li>Пользователи действительно проходили все тесты за 1–2 дня — до окончания trial.<\/li>\n<li>Видели, что сертификат недоступен, и не хотели ждать.<\/li>\n<li>Шли в настройки приложения → Управление подписками → выбирали другую подписку из той же группы, но без триала — и оформляли её.<\/li>\n<li>У обеих подписок был один и тот же <samp>groupID<\/samp>, и обе находились в <samp>currentEntitlements<\/samp>.<\/li>\n<\/ul>\n<p>Вот только одна — с trial, а вторая — платная. Мы не предполгагали что в один момент может существовать две транзакции и считали что при оформлении подписки в той же группе транзакция с триалом протузнет и будет только одна. Да чего уже там, мы вообще не предполагали что такая комбинация возможна — мы перестали предлагать опцию без триала на всех пейволах.<\/p>\n<p>То есть у нас в логике было примерно так (очень упрощенно):<\/p>\n<pre class=\"e2-text-code\"><code class=\"swift\">\r\nextension Transaction {\r\n    \/\/\/ Триальная ли транзакция\r\n    var isTrial: Bool {\r\n        guard \r\n                let expirationDate = expirationDate, \r\n                Date() < expirationDate \r\n        else { return false }\r\n        return offer?.paymentMode == .freeTrial\r\n    }\r\n\r\n    \/\/\/ Есть ли триальная транзакция у пользователя\r\n    static var hasTrialSubscription: Bool {\r\n        get async {\r\n            for await result in Transaction.currentEntitlements {\r\n                do {\r\n                    if try result.payloadValue.isTrial {\r\n                        return true\r\n                    }\r\n                } catch { continue }\r\n            }\r\n            return false\r\n        }\r\n    }\r\n}\r\n<\/code>\n<\/pre>\n<p><br \/><\/p>\n<div class=\"callout\"><div class=\"pin\"><p>☝🏼<\/p>\n<\/div><p>При смене одной подписки через настройки на другую (в той же группе) во время действия триала, появляется две активные транзакции: первая на время действия триала и вторая с оплаченной подпиской, с одинаковым <samp>originalTransactionId<\/samp>.<\/p>\n<\/div><p><br \/><\/p>\n<pre class=\"e2-text-code\"><code class=\"swift\">\r\nextension Transaction {\r\n    \/\/\/ Триальная ли транзакция\r\n    var isTrial: Bool {\r\n        guard let expirationDate = expirationDate, Date() < expirationDate else { return false }\r\n        return offer?.paymentMode == .freeTrial\r\n    }\r\n\r\n    \/\/\/ Есть ли триальная транзакция у пользователя\r\n    static var hasTrialSubscription: Bool {\r\n        get async {\r\n            for await result in Transaction.currentEntitlements {\r\n                do {\r\n                    if try result.payloadValue.isTrial {\r\n                        return true\r\n                    }\r\n                } catch { continue }\r\n            }\r\n            return false\r\n        }\r\n    }\r\n}\r\n<\/code>\n<\/pre>\n<h3>Решение<\/h3>\n<p>Решение очень простое — нужно искать последнюю транзакцию с одним <samp>originalID<\/samp> и уже у неё проверять статус оффера.<\/p>\n<pre class=\"e2-text-code\"><code class=\"swift\">\r\nextension Transaction {\r\n    \/\/\/ Есть ли триальная транзакция у пользователя\r\n    static var hasTrialSubscription: Bool {\r\n        get async {\r\n            var latestTransactions: [UInt64: StoreKit.Transaction] = [:]\r\n            for await result in Transaction.currentEntitlements {\r\n                do {\r\n                    let transaction = try result.payloadValue\r\n                    if\r\n                        let latestTransaction = latestTransactions[transaction.originalID],\r\n                        transaction.purchaseDate < latestTransaction.purchaseDate\r\n                    {  continue }\r\n                    latestTransactions[transaction.originalID] = transaction\r\n                } catch { continue }\r\n            }\r\n            return latestTransactions.values.contains(where: { $0.isTrial })\r\n        }\r\n    }\r\n}\r\n<\/code>\n<\/pre>\n<h3>Вывод еще раз<\/h3>\n<p>StoreKit не инвалидирует первую триальную транзакцию из entitlements, даже если пользователь сменил подписку внутри группы. И это не баг, а особенность. Просто её надо учитывать, особенно если вы ограничиваете функциональность на trial-период.<\/p>\n<p>Заметка в духе «запиши, чтобы потом не искать». Надеюсь, кому-то сэкономит пару часов и несколько тикетов в поддержку.<\/p>\n<p>Буду рад вашей подписке на мой <a href=\"https:\/\/t.me\/sidorovtech\">мой телеграм канал<\/a>!<\/p>\n",
            "date_published": "2025-06-26T22:46:02+00:00",
            "date_modified": "2025-06-27T08:43:15+00:00",
            "_date_published_rfc2822": "Thu, 26 Jun 2025 22:46:02 +0000",
            "_rss_guid_is_permalink": "false",
            "_rss_guid": "9",
            "_e2_data": {
                "is_favourite": false,
                "links_required": [
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css",
                    "system\/library\/highlight\/highlight.js",
                    "system\/library\/highlight\/highlight.css"
                ],
                "og_images": []
            }
        }
    ],
    "_e2_version": 3576,
    "_e2_ua_string": "E2 (v3576; Aegea)"
}