iOS in-app purchases: серверная валидация покупок

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!

iOS in-app purchases: серверная валидация покупок

Всем привет, я Кирилл, СТО Adapty. Я делал систему серверной валидации для наших SDK.

Сегодня как раз и расскажу про серверную верификацию покупок на iOS. Это третья статья из серии о внедрении внутренних покупок на iOS, советую познакомиться с остальными:

  1. Конфигурация внутренних покупок и добавление в проект.

  2. Инициализация и обработка покупки.

  3. Серверная верификация покупок.

    И подпишитесь, чтобы не пропустить следующие статьи:

  4. Локальное тестирование покупок в XCode.

  5. Обработка ошибок.

  6. Промо-офферы.

  7. Как упростить внедрение внутренних покупок с Adapty.

Что такое серверная валидация покупки?

Серверная валидация (server-side receipt validation) — это способ проверить подлинность покупки. В отличие от проверки покупки на устройстве, серверная валидация происходит, внимание, на сервере. Валидация означает, что устройство или сервер обращаются к серверам Apple и спрашивают, действительно ли была покупка и валидная ли она.

Зачем валидировать покупки

Стоит отметить, что серверная валидация не обязательна, встроенные покупки и подписки будут работать и без неё. Но она даёт ряд преимуществ:

  1. Продвинутая аналитика платежей, особенно актуальная для подписок, потому что все события после активации происходят без участия устройства. Если не сделать серверную обработку покупок, вы не будете знать статус подписки в конкретный момент времени: продлил ли пользователь подписку, отменил ее, есть ли проблема с платежом и тд.

  2. Проверка подлинности покупки, то есть вы будете уверены, что это не фродовая транзакция, а значит, пользователь заплатил деньги за ваш продукт.

  3. Кроссплатформенные подписки. Зная в живом режиме статус подписки пользователя, можно синхронизировать его с другими платформами. Например, пользователь, оформивший подписку на iOS, сможет пользоваться сервисом на Android, Web и других платформах.

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

По нашему опыту, один первый пункт — достаточная причина делать обработку покупок на сервере.

Валидация платежей

В целом процесс валидации платежей на iOS можно описать схемой:

Получение shared secret

Чтобы отправить запрос на валидацию платежа, необходимо предоставить shared secret, с помощью которого авторизуется запрос. Получить его можно в App Store Connect.

Shared secret может быть создан для конкретного приложения (app-specific) или же для всех приложений в аккаунте (primary).

Чтобы получить secret для конкретного приложения, необходимо открыть страницу данного приложения в App Store Connect, перейти в раздел In-App Purchases → Manage и кликнуть на ссылку App-Specific Shared Secret. В открывшемся окне можно будет создать новый или скопировать существующий токен.

Чтобы получить secret для всех приложений в аккаунте, необходимо открыть страницу Users and Access и перейти на вкладку Shared Secret.

Запрос на валидацию платежа

После получения shared secret вы можете отправлять квитанцию (receipt) для валидации на сервер Apple. Это делается с помощью запроса verifyReceipt. Нужно отправить POST-запрос на https://buy.itunes.apple.com/verifyReceipt. В JSON теле запроса нужно отправить shared secret в поле password и receipt в поле receipt-data. Также есть опциональный параметр exclude-old-transactions. Если его значение true, то для каждой авто-возобновляемой подписки вернётся только последняя транзакция, вместо всей истории продлений.

Пейлоад запроса на валидацию платежа
{
    "password": "f4d35830e3...52aae",
    "receipt-data": "MIIUVQY...4rVpL8NlYh2/8l7rk0BcStXjQ==",
    "exclude-old-transactions": false
}

Если вы работаете в Sandbox режиме, то есть тестируете покупки, запросы на валидацию надо отправлять на https://sandbox.itunes.apple.com/verifyReceipt. Shared secret, формат пейлоада и ответа остаются без изменений.

Важно отметить, что receipt, созданный в Sandbox, нельзя будет провалидировать на Production сервере, и наоборот.

Поэтому в реальных системах рекомендуется всегда первый запрос отправлять на Production сервер, и, если в ответ в ключе status пришла ошибка с кодом 21007, повторить запрос на Sandbox сервер. Такое поведение обязательно нужно включать на период ревью приложения, чтобы сотрудники Apple могли тестировать покупки, и в это же время их могут делать реальные пользователи вашего приложения.

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

В случае успешной валидации (status=0), в ответ приходит информация о транзакциях пользователя.

Ответ на запрос о валидации платежа
{
  "environment": "Production",
  "receipt": {
    "receipt_type": "Production",
    "adam_id": 123,
    "app_item_id": 123,
    "bundle_id": "com.adapty.sample_app",
    "application_version": "1",
    "download_id": 123,
    "version_external_identifier": 123,
    "receipt_creation_date": "2021-04-28 19:42:01 Etc/GMT",
    "receipt_creation_date_ms": "1619638921000",
    "receipt_creation_date_pst": "2021-04-28 12:42:01 America/Los_Angeles",
    "request_date": "2021-08-09 18:26:02 Etc/GMT",
    "request_date_ms": "1628533562696",
    "request_date_pst": "2021-08-09 11:26:02 America/Los_Angeles",
    "original_purchase_date": "2017-04-09 21:18:41 Etc/GMT",
    "original_purchase_date_ms": "1491772721000",
    "original_purchase_date_pst": "2017-04-09 14:18:41 America/Los_Angeles",
    "original_application_version": "1",
    "in_app": [
      {
        "quantity": "1",
        "product_id": "basic_subscription_1_month",
        "transaction_id": "1000000831360853",
        "original_transaction_id": "1000000831360853",
        "purchase_date": "2021-04-28 19:41:58 Etc/GMT",
        "purchase_date_ms": "1619638918000",
        "purchase_date_pst": "2021-04-28 12:41:58 America/Los_Angeles",
        "original_purchase_date": "2021-04-28 19:41:58 Etc/GMT",
        "original_purchase_date_ms": "1619638918000",
        "original_purchase_date_pst": "2021-04-28 12:41:58 America/Los_Angeles",
        "expires_date": "2021-05-05 19:41:58 Etc/GMT",
        "expires_date_ms": "1620243718000",
        "expires_date_pst": "2021-05-05 12:41:58 America/Los_Angeles",
        "web_order_line_item_id": "230000397200750",
        "is_trial_period": "true",
        "is_in_intro_offer_period": "false",
        "in_app_ownership_type": "PURCHASED"
      }
    ]
  },
  "latest_receipt_info": [
    {
      "quantity": "1",
      "product_id": "basic_subscription_1_month",
      "transaction_id": "230001020690335",
      "original_transaction_id": "1000000831360853",
      "purchase_date": "2021-08-04 19:41:58 Etc/GMT",
      "purchase_date_ms": "1628106118000",
      "purchase_date_pst": "2021-08-04 12:41:58 America/Los_Angeles",
      "original_purchase_date": "2021-04-28 19:41:58 Etc/GMT",
      "original_purchase_date_ms": "1619638918000",
      "original_purchase_date_pst": "2021-04-28 12:41:58 America/Los_Angeles",
      "expires_date": "2021-08-11 19:41:58 Etc/GMT",
      "expires_date_ms": "1628710918000",
      "expires_date_pst": "2021-08-11 12:41:58 America/Los_Angeles",
      "web_order_line_item_id": "230000438372383",
      "is_trial_period": "false",
      "is_in_intro_offer_period": "false",
      "in_app_ownership_type": "PURCHASED",
      "subscription_group_identifier": "272394410"
    },
    {
      "quantity": "1",
      "product_id": "basic_subscription_1_month",
      "transaction_id": "230001017218955",
      "original_transaction_id": "1000000831360853",
      "purchase_date": "2021-07-28 19:41:58 Etc/GMT",
      "purchase_date_ms": "1627501318000",
      "purchase_date_pst": "2021-07-28 12:41:58 America/Los_Angeles",
      "original_purchase_date": "2021-04-28 19:41:58 Etc/GMT",
      "original_purchase_date_ms": "1619638918000",
      "original_purchase_date_pst": "2021-04-28 12:41:58 America/Los_Angeles",
      "expires_date": "2021-08-04 19:41:58 Etc/GMT",
      "expires_date_ms": "1628106118000",
      "expires_date_pst": "2021-08-04 12:41:58 America/Los_Angeles",
      "web_order_line_item_id": "230000849023623",
      "is_trial_period": "false",
      "is_in_intro_offer_period": "false",
      "in_app_ownership_type": "PURCHASED",
      "subscription_group_identifier": "272394410"
    }
  ],
  "latest_receipt": "MIIUVQY...4rVpL8NlYh2/8l7rk0BcStXjQ==",
  "pending_renewal_info": [
    {
      "auto_renew_product_id": "basic_subscription_1_month",
      "product_id": "basic_subscription_1_month",
      "original_transaction_id": "1000000831360853",
      "auto_renew_status": "1"
    }
  ],
  "status": 0
}

Ответ громоздкий, и в новой версии App Store Server API его упростили, но и с текущей реализацией нетрудно разобраться.

Получение статуса подписки и истории транзакций

Чтобы понимать, есть ли у пользователя доступ к платным функциям приложения, вам нужно уметь определять статус подписки. В данной версии API нет отдельного запроса на получение статуса подписки, поэтому вам в любом случае будет нужно работать с историей транзакций.

В latest_receipt_info по умолчанию хранятся все транзакции пользователя, за исключением consumable продуктов, которые завершены на стороне мобильного приложения. Таким образом, вы можете восстановить всю историю покупок пользователя. Это полезно и для аналитики, и для вычисления текущего статуса подписки.

Кажется, что сейчас транзакции всегда приходят отсортированными от самой старой к самой новой, но для надёжности я рекомендую сделать собственную сортировку по дате транзакций.

Пейлоад транзакции
{
	"quantity":"1",
	"product_id":"basic_subscription_1_month",
	"transaction_id":"1000000831360853",
	"original_transaction_id":"1000000831360853",
	"purchase_date":"2021-04-28 19:41:58 Etc/GMT",
	"purchase_date_ms":"1619638918000",
	"purchase_date_pst":"2021-04-28 12:41:58 America/Los_Angeles",
	"original_purchase_date":"2021-04-28 19:41:58 Etc/GMT",
	"original_purchase_date_ms":"1619638918000",
	"original_purchase_date_pst":"2021-04-28 12:41:58 America/Los_Angeles",
	"expires_date":"2021-05-05 19:41:58 Etc/GMT",
	"expires_date_ms":"1620243718000",
	"expires_date_pst":"2021-05-05 12:41:58 America/Los_Angeles",
	"web_order_line_item_id":"230000397200750",
	"is_trial_period":"true",
	"is_in_intro_offer_period":"false",
	"in_app_ownership_type":"PURCHASED",
	"subscription_group_identifier":"272394410"
}

Для получения текущего статуса подписки достаточно взять хронологически последнюю транзакцию в цепочке и посмотреть на дату истечения подписки (expires_date). Исключением будет grace period, о нём мы поговорим чуть позже.

Для аналитических целей я рекомендую сохранять следующие поля:

  • product_id — текстовый идентификатор продукта, который был куплен.

  • transaction_id — числовой уникальный идентификатор транзакции. У каждой покупки/продления будет свой идентификатор, его можно использовать, чтобы понимать, была ли ранее обработана данная транзакция.

  • original_transaction_id — числовой уникальный идентификатор цепочки транзакций. При активации подписки/триала он будет совпадать с transaction_id, но при последующих продлениях transaction_id будет меняться, а original_transaction_id будет одинаковым. Это удобно для того, чтобы отслеживать количество продлений.

  • purchase_date и original_purchase_date — дата транзакции и дата оригинальной транзакции по аналогии с предыдущем пунктом.

  • expires_date — дата истечения подписки

  • cancellation_date — дата рефанда, а не отмены подписки, как может показаться из названия. Если в ответе есть это поле, значит, вы можете отозвать доступ пользователя к приложению, а также учесть в аналитике, что деньги за эту транзакцию не будут выплачены.

  • is_in_intro_offer_period — флаг, показывающий, был ли использован интро оффер при активации подписки.

  • is_trial_period — флаг, показывающий, был ли использован триал при активации подписки.

  • offer_code_ref_name — оффер код, который был использован при активации подписки.

  • promotional_offer_id — текстовый идентификатор промо оффера, который был использован при переходе на текущий период подписки.

  • in_app_ownership_type — тип владения подпиской, отвечает на вопрос, купил ли пользователь продукт сам или получил его в рамках семейной подписки. Возможные значения:

    • PURCHASED — пользователь сам купил продукт.

    • FAMILY_SHARED — пользователь получил продукт в рамках семейной подписки.

На WWDC 2021 Apple сообщили, что позже в транзакцию добавят поле appAccountToken, которое содержит идентификатор пользователя в вашей системе. Этот идентификатор должен быть в формате UUID и задаётся на стороне мобильного приложения в момент инициализации покупки. Если задан, то он будет возвращаться во всех транзакциях в этой цепочке (продления, проблемы с биллингом и тд.), а значит вы легко сможете понять, какой пользователь сделал покупку.

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

Информация о продлении подписки, grace period и billing issue

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

Информация о продлении подписки
{
       "auto_renew_product_id": "basic_subscription_1_month",
       "product_id": "basic_subscription_1_month",
       "original_transaction_id": "1000000831360853",
       "auto_renew_status": "1"
}
  • product_id — текстовый идентификатор продукта, который был куплен.

  • auto_renew_product_id — текстовый идентификатор продукта, который активируется в следующем периоде. Если он отличается от текущего (product_id), значит пользователь сменил тип подписки.

  • auto_renew_status — флаг, показывающий будет ли продлена подписка на следующий период.

  • expiration_intent — причина истечения подписки. Возможные значения:

    • 1 — пользователь сам отменил подписку.

    • 2 — подписка отменилась из-за проблем с оплатой.

    • 3 — пользователь не согласился на повышение цены

    • 4 — продукт подписки не был доступен для продления, например, если его удалили из App Store Connect.

    • 5 — неизвестная причина.

  • grace_period_expires_date — дата истечения grace period, если он включен для вашего приложения. В этом случае пользователю должны быть доступны платные функции приложения до даты, указанной здесь, а не до той, которая указана в самой транзакции. Если есть этот ключ, вы можете сообщить пользователю о том, что ему нужно обновить данные карты для оплаты или пополнить баланс.

  • is_in_billing_retry_period — флаг, показывающий находится ли подписка в статусе billing retry. Это значит, что подписка не была отменена, но при этом Apple не смог списать деньги за продление и будет пытаться делать это в течение 60 дней.

  • offer_code_ref_name — оффер код, который будет использован в следующем периоде подписки.

  • promotional_offer_id — текстовый идентификатор промо оффера, который будет использован в следующем периоде подписки.

  • price_consent_status — флаг, показывающий, согласился ли пользователь с грядущим повышением цены на подписку. Если его значение 0, то вам стоит предложить пользователю другой продукт или промо оффер, чтобы он не прекратил подписку.

Consumable, non-consumable и невозобновляемые подписки

Если у пользователя нет авто-возобновляемых продуктов, то ключи latest_receipt_info и pending_renewal_info не возвращаются. В таком случае, транзакции стоит искать в receiptin_app. Формат транзакций похож на авто-возобновляемые транзакции, но в нём нет полей истечения, продления, офферов и других эксклюзивных для авто-возобновляемых транзакций параметров.

Стоит отметить, что receiptin_app приходит и для авто-возобновляемых транзакций, но лучше использовать latest_receipt_info, потому что в нём будет наиболее актуальная информация по подписке.

Серверные уведомления о транзакциях

Некоторое время назад требовалось написать сложную систему, чтобы отслеживать изменения в состоянии подписок. Например, чтобы понять, продлилась подписка или нет, нужно было за сутки до её истечения раз в час отправлять запрос на сервер Apple, чтобы узнать её статус. Apple постепенно добавлял серверные уведомления, и в настоящее время практически все важные события, связанные с подписками, покрыты серверными уведомлениями. Это очень удобно: как только со стороны Apple произошло какое-то изменение, вы можете получить информацию об этом на своём сервере. То есть вы будете получать информацию о новых покупках, продлениях, проблемах с платежами и так далее. Это позволяет собирать более точную аналитику, а также упрощает менеджмент состояния подписчика.

Настроить получение серверных уведомлений можно в App Store Connect. Необходимо открыть страницу приложения, перейти в раздел General → App Information, указать желаемую ссылку в поле URL for App Store Server Notifications и сохранить изменения.

Серверное уведомление
{
        "notification_type": "DID_RENEW",
        "password": "f4d35830e3...52aae",
        "environment": "PROD",
        "auto_renew_product_id": "basic_subscription_1_month",
        "auto_renew_status": "true",
        "unified_receipt": {
            "status": 0,
            "environment": "Production",
            "latest_receipt_info": [
                {
            "quantity": "1",
            "product_id": "basic_subscription_1_month",
            "transaction_id": "230001020690335",
            "original_transaction_id": "1000000831360853",
            "purchase_date": "2021-08-04 19:41:58 Etc/GMT",
            "purchase_date_ms": "1628106118000",
            "purchase_date_pst": "2021-08-04 12:41:58 America/Los_Angeles",
            "original_purchase_date": "2021-04-28 19:41:58 Etc/GMT",
            "original_purchase_date_ms": "1619638918000",
            "original_purchase_date_pst": "2021-04-28 12:41:58 America/Los_Angeles",
            "expires_date": "2021-08-11 19:41:58 Etc/GMT",
            "expires_date_ms": "1628710918000",
            "expires_date_pst": "2021-08-11 12:41:58 America/Los_Angeles",
            "web_order_line_item_id": "230000438372383",
            "is_trial_period": "false",
            "is_in_intro_offer_period": "false",
            "in_app_ownership_type": "PURCHASED",
            "subscription_group_identifier": "272394410"
        },
        {
            "quantity": "1",
            "product_id": "basic_subscription_1_month",
            "transaction_id": "230001017218955",
            "original_transaction_id": "1000000831360853",
            "purchase_date": "2021-07-28 19:41:58 Etc/GMT",
            "purchase_date_ms": "1627501318000",
            "purchase_date_pst": "2021-07-28 12:41:58 America/Los_Angeles",
            "original_purchase_date": "2021-04-28 19:41:58 Etc/GMT",
            "original_purchase_date_ms": "1619638918000",
            "original_purchase_date_pst": "2021-04-28 12:41:58 America/Los_Angeles",
            "expires_date": "2021-08-04 19:41:58 Etc/GMT",
            "expires_date_ms": "1628106118000",
            "expires_date_pst": "2021-08-04 12:41:58 America/Los_Angeles",
            "web_order_line_item_id": "230000849023623",
            "is_trial_period": "false",
            "is_in_intro_offer_period": "false",
            "in_app_ownership_type": "PURCHASED",
            "subscription_group_identifier": "272394410"
        }

            ],
            "latest_receipt": "MIIUVQY...4rVpL8NlYh2/8l7rk0BcStXjQ==",
            "pending_renewal_info": [
                {
                    "auto_renew_status": "1",
                    "auto_renew_product_id": "basic_subscription_1_month",
                    "product_id": "basic_subscription_1_month",
                    "original_transaction_id": "1000000831360853"
                }
            ]
        },
        "bid": "com.adapty.sample_app",
        "bvrs": "0"
    }

Формат серверных уведомлений похож на ответ валидации платежа. Информация о транзакциях лежит в unified_receiptlatest_receipt_info. В ключе password лежит shared secret для вашего приложения, таким образом вы можете убедиться в подлинности запроса. В ключе notification_type указан тип события. На мой взгляд, самые полезные:

  • DID_CHANGE_RENEWAL_STATUS — пользователь выключил или (намного реже) включил автопродление подписки. Если автопродление выключили, надо пытаться вернуть пользователя в число активных подписчиков.

  • DID_FAIL_TO_RENEW — подписку не получилось продлить из-за проблем с оплатой. Стоит сообщить пользователю об этом, чтобы у него не отменилась подписка автоматически.

  • DID_RENEW — подписка успешно продлилась.

  • REFUND — пользователю вернули деньги за покупку. Нужно закрыть ему доступ к функциям, которые давала эта покупка и отразить рефанд (потерю денег) в аналитике.

Заключение

Серверная валидация значительно улучшает качество аналитики, которую вы можете собирать со своего приложения. Она также усложняет несанкционированный доступ к платному контенту и позволяет делать мультиплатформенные подписки. При этом реализация валидации может занять довольно много времени, особенно если нужна высокая точность данных, а значит надо учитывать много сайд-кейсов: upgrade подписки, cross grade подписки, trial период, promo/introductory offers, grace period, возвраты, семейные подписки и тд. Плюс нужно знать и учитывать различные нюансы, например, что для подписок, которые продлеваются больше года, Apple снижает комиссию с 30% до 15%.

Если вы хотите получить работающую серверную валидацию и продвинутую аналитику с самого начала жизни вашего приложения и не хотите разрабатывать всё это самостоятельно, вам стоит попробовать Adapty. Мы делаем этот сервис не только для технической реализации подписок:

  • Встроенная аналитика позволяет быстро понять основные метрики приложения.

  • Когортный анализ отвечает на вопрос, как быстро сходится экономика.

  • А/Б тесты увеличивают выручку приложения.

  • Интеграции с внешними системами позволяют отправлять транзакции в сервисы атрибуции и продуктовой аналитики.

  • Промо кампании уменьшают отток аудитории.

  • Open source SDK позволяет интегрировать подписки в приложение за несколько часов.

  • Серверная валидация и API для работы с другими платформами.

Познакомьтесь подробнее с этими возможностями, чтобы быстрее внедрить подписки в своё приложение и улучшить конверсии.

Источник: https://habr.com/ru/company/adapty/blog/572370/


Интересные статьи

Интересные статьи

Бессерверные вычисления (или serverless-технологии, как их иногда называют) — это перспективная технологическая модель облачных вычислений, появившаяся на горизонте прикл...
Выгрузка пользователей из 1C ЗУП в Битрикс24 или правдивая история о том как настроить интеграцию 1С-Битрикс24 с ЗУП без 1С-ника В жизни так бывает, причём бывает чаще чем хотелось б...
Недавно на проекте интегрировал модуль CRM Битрикса c виртуальной АТС Ростелеком. Делал по стандартной инструкции, где пошагово показано, какие поля заполнять. Оказалось, следование ей не гаран...
Много всякого сыпется в мой ящик, в том числе и от Битрикса (справедливости ради стоит отметить, что я когда-то регистрировался на их сайте). Но вот мне надоели эти письма и я решил отписатьс...
Компании растут и меняются. Если для небольшого бизнеса легко прогнозировать последствия любых изменений, то у крупного для такого предвидения — необходимо изучение деталей.