Русский
Русский
English
Статистика
Реклама

Subscriptions

Разворачиваем сервер для проверки In-app purchase за 60 минут

12.11.2020 06:13:44 | Автор: admin

Всем привет! Сегодня расскажу вам как развернуть сервер для проверки In-app Purchase и In-app Subscription для iOS и Android (server-server validation).


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


В проверке чеков покупок нет никакой технической сложности, по факту сервер просто проксирует запрос и сохраняет данные о покупке.




То есть задачу такого сервера можно разделить на 4 этапа:


  • Получение запроса с чеком, отправленным приложением после покупки
  • Запрос в Apple/Google на проверку чека
  • Сохранение данных о транзакции
  • Ответ приложению

В рамках статьи опустим 3 пункт, ибо он сугубо индивидуален.


Код в статье будет написан на Node.js, но по сути логика универсальна и не составит труда использовать ее написать валидацию на любом языке программирования.


Еще есть статья хорошая То, что нужно знать о проверке чека App Store (App Store receipt), ребята делают сервис для работы с подписками. В статье детально описано, что такое чек (receipt) и для чего нужна проверка покупок.


Сразу скажу, что в сниппетах кода используются вспомогательные классы и интерфейсы, весь код доступен в репозитории по ссылке https://github.com/denjoygroup/inapppurchase. В приведенном ниже фрагментах кода, я постарался дать названия используемым методам такие, чтобы приходилось делать отсылки к этим функциям.


iOS


Для проверки вам нужен Apple Shared Secret это ключ, который вы должны получить в iTunnes Connect, он нужен для проверки чеков.


В первую очередь зададим параметры для создания запросов:


 apple: any = {    password: process.env.APPLE_SHARED_SECRET, // ключ, укажите свой    host: 'buy.itunes.apple.com',    sandbox: 'sandbox.itunes.apple.com',    path: '/verifyReceipt',    apiHost: 'api.appstoreconnect.apple.com',    pathToCheckSales: '/v1/salesReports' }

Теперь создадим функцию для отправки запроса. В зависимости от среды, с которой работаете, вы должны отправлять запрос либо на sandbox.itunes.apple.com для тестовых покупок, либо в прод buy.itunes.apple.com


/*** receiptValue - чек, который проверяете* sandBox - среда разработк**/async _verifyReceipt(receiptValue: string, sandBox: boolean) {    let options = {        host: sandBox ? this._constants.apple.sandbox : this._constants.apple.host,        path: this._constants.apple.path,        method: 'POST'    };    let body = {        'receipt-data': receiptValue,        'password': this._constants.apple.password    };    let result = null;    let stringResult = await this._handlerService.sendHttp(options, body, 'https');    result = JSON.parse(stringResult);    return result;}

Если запрос прошел успешно, то в ответе от сервера Apple в поле status вы получите данные о вашей покупке.


У статуса возможны несколько значений, в зависимости от которых вы должны обработать покупку


21000 Запрос был отправлен не методом POST


21002 Чек поврежден, не удалось его распарсить


21003 Некорректный чек, покупка не подтверждена


21004 Ваш Shared Secret некорректный или не соответствует чеку


21005 Сервер эпла не смог обработать ваш запрос, стоит попробовать еще раз


21006 Чек недействителен


21007 Чек из SandBox (тестовой среды), но был отправлен в prod


21008 Чек из прода, но был отправлен в тестовую среду


21009 Сервер эпла не смог обработать ваш запрос, стоит попробовать еще раз


21010 Аккаунт был удален


0 Покупка валидна


Пример ответа от iTunnes Connect выглядит следующим образом


{    "environment":"Production",    "receipt":{        "receipt_type":"Production",        "adam_id":1527458047,        "app_item_id":1527458047,        "bundle_id":"BUNDLE_ID",        "application_version":"0",        "download_id":34089715299389,        "version_external_identifier":838212484,        "receipt_creation_date":"2020-11-03 20:47:54 Etc/GMT",        "receipt_creation_date_ms":"1604436474000",        "receipt_creation_date_pst":"2020-11-03 12:47:54 America/Los_Angeles",        "request_date":"2020-11-03 20:48:01 Etc/GMT",        "request_date_ms":"1604436481804",        "request_date_pst":"2020-11-03 12:48:01 America/Los_Angeles",        "original_purchase_date":"2020-10-26 19:24:19 Etc/GMT",        "original_purchase_date_ms":"1603740259000",        "original_purchase_date_pst":"2020-10-26 12:24:19 America/Los_Angeles",        "original_application_version":"0",        "in_app":[            {                "quantity":"1",                "product_id":"PRODUCT_ID",                "transaction_id":"140000855642848",                "original_transaction_id":"140000855642848",                "purchase_date":"2020-11-03 20:47:53 Etc/GMT",                "purchase_date_ms":"1604436473000",                "purchase_date_pst":"2020-11-03 12:47:53 America/Los_Angeles",                "original_purchase_date":"2020-11-03 20:47:54 Etc/GMT",                "original_purchase_date_ms":"1604436474000",                "original_purchase_date_pst":"2020-11-03 12:47:54 America/Los_Angeles",                "expires_date":"2020-12-03 20:47:53 Etc/GMT",                "expires_date_ms":"1607028473000",                "expires_date_pst":"2020-12-03 12:47:53 America/Los_Angeles",                "web_order_line_item_id":"140000337829668",                "is_trial_period":"false",                "is_in_intro_offer_period":"false"            }        ]    },    "latest_receipt_info":[        {            "quantity":"1",            "product_id":"PRODUCT_ID",            "transaction_id":"140000855642848",            "original_transaction_id":"140000855642848",            "purchase_date":"2020-11-03 20:47:53 Etc/GMT",            "purchase_date_ms":"1604436473000",            "purchase_date_pst":"2020-11-03 12:47:53 America/Los_Angeles",            "original_purchase_date":"2020-11-03 20:47:54 Etc/GMT",            "original_purchase_date_ms":"1604436474000",            "original_purchase_date_pst":"2020-11-03 12:47:54 America/Los_Angeles",            "expires_date":"2020-12-03 20:47:53 Etc/GMT",            "expires_date_ms":"1607028473000",            "expires_date_pst":"2020-12-03 12:47:53 America/Los_Angeles",            "web_order_line_item_id":"140000447829668",            "is_trial_period":"false",            "is_in_intro_offer_period":"false",            "subscription_group_identifier":"20675121"        }    ],    "latest_receipt":"RECEIPT",    "pending_renewal_info":[        {            "auto_renew_product_id":"PRODUCT_ID",            "original_transaction_id":"140000855642848",            "product_id":"PRODUCT_ID",            "auto_renew_status":"1"        }    ],    "status":0}

Также перед отправкой запроса и после отправки стоит сверить id продукта, который запрашивает клиент и который мы получаем в ответе.


Полезная для нас информация содержится в свойствах in_app и latest_receipt_info, и на первый взгляд содержимое этих свойств идентичны, но:


latest_receipt_info содержит все покупки.


in_app содержит Non-consumable и Non-Auto-Renewable покупки.


Будем использовать latest_receipt_info, соотвественно в этом массиве ищем нужный нам продукт по свойству product_id и проверяем дату, если это подписка. Конечно, стоит еще проверить не начислили ли мы уже эту покупку пользователю, особенно актуально для Consumable Purchase. Проверять можно по свойству original_transaction_id, заранее сохранив в базе, но в рамках этого гайдлайна мы этого делать не будем.


Тогда проверка покупки будет выглядеть примерно так


/*** product - id покупки* resultFromApple - ответ от Apple, полученный выше* productType - тип покупки (подписка, расходуемая или non-consumable)* sandBox - тестовая среда или нет***/async parseResponse(product: string, resultFromApple: any, productType: ProductType, sandBox: boolean) {    let parsedResult: IPurchaseParsedResultFromProvider = {        validated: false,        trial: false,        checked: false,        sandBox,        productType: productType,        lastResponseFromProvider: JSON.stringify(resultFromApple)    };    switch (resultFromApple.status) {        /**        * Валидная подписка        */        case 0: {            /**            * Ищем в ответе информацию о транзакции по запрашиваемому продукту            **/            let currentPurchaseFromApple = this.getCurrentPurchaseFromAppleResult(resultFromApple, product!, productType);            if (!currentPurchaseFromApple) break;            parsedResult.checked = true;            parsedResult.originalTransactionId = this.getTransactionIdFromAppleResponse(currentPurchaseFromApple);            if (productType === ProductType.Subscription) {                parsedResult.validated = (this.checkDateIsAfter(currentPurchaseFromApple.expires_date_ms)) ? true : false;                parsedResult.expiredAt = (this.checkDateIsValid(currentPurchaseFromApple.expires_date_ms)) ?                this.formatDate(currentPurchaseFromApple.expires_date_ms) : undefined;            } else {                parsedResult.validated = true;            }            parsedResult.trial = !!currentPurchaseFromApple.is_trial_period;            break;        }        default:            if (!resultFromApple) console.log('empty result from apple');            else console.log('incorrect result from apple, status:', resultFromApple.status);    }    return parsedResult;}

После этого можно возвращать ответ на клиент по нашей покупке, которая хранится в переменной parsedResult. Формировать структуру этого объекта вы можете по своему усмотрению, зависит от ваших потребностей, но самое главное, что на этом шаге мы уже знаем валидна покупка или нет, и информацию об этом хранится в parsedResult.validated.


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


Android


Для гугла достаточно сильно отличается формат запроса, ибо сначала надо авторизоваться посредством OAuth и потом только отправлять запрос на проверку покупки.


Для гугла нам понадобится чуть больше входных параметров:


google: any = {    host: 'androidpublisher.googleapis.com',    path: '/androidpublisher/v3/applications',    email: process.env.GOOGLE_EMAIL,    key: process.env.GOOGLE_KEY,    storeName: process.env.GOOGLE_STORE_NAME}

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


Окей, гугл, прими запрос:


/*** product - название продукта* token - чек* productType  тип покупки, подписка или нет**/async getPurchaseInfoFromGoogle(product: string, token: string, productType: ProductType) {    try {        let options = {            email: this._constants.google.email,            key: this._constants.google.key,            scopes: ['https://www.googleapis.com/auth/androidpublisher'],        };        const client = new JWT(options);        let productOrSubscriptionUrlPart = productType === ProductType.Subscription ? 'subscriptions' : 'products';        const url = `https://${this._constants.google.host}${this._constants.google.path}/${this._constants.google.storeName}/purchases/${productOrSubscriptionUrlPart}/${product}/tokens/${token}`;        const res = await client.request({ url });        return res.data as ResultFromGoogle;    } catch(e) {        return e as ErrorFromGoogle;    }}

Для авторизации воспользуемся библиотекой google-auth-library и класс JWT.


Ответ от гугла выглядит примерно так:


{    startTimeMillis: "1603956759767",    expiryTimeMillis: "1603966728908",    autoRenewing: false,    priceCurrencyCode: "RUB",    priceAmountMicros: "499000000",    countryCode: "RU",    developerPayload: {        "developerPayload":"",        "is_free_trial":false,        "has_introductory_price_trial":false,        "is_updated":false,        "accountId":""    },    cancelReason: 1,    orderId: "GPA.3335-9310-7555-53285..5",    purchaseType: 0,    acknowledgementState: 1,    kind: "androidpublisher#subscriptionPurchase"}

Теперь перейдем к проверке покупки



parseResponse(product: string, result: ResultFromGoogle | ErrorFromGoogle, type: ProductType) {    let parsedResult: IPurchaseParsedResultFromProvider = {        validated: false,        trial: false,        checked: true,        sandBox: false,        productType: type,        lastResponseFromProvider: JSON.stringify(result),    };    if (this.isResultFromGoogle(result)) {        if (this.isSubscriptionResult(result)) {            parsedResult.expiredAt = moment(result.expiryTimeMillis, 'x').toDate();            parsedResult.validated = this.checkDateIsAfter(parsedResult.expiredAt);        } else if (this.isProductResult(result)) {            parsedResult.validated = true;        }    }    return parsedResult;}

Тут все достаточно тривиально. На выходе мы также получаем parsedResult, где самое важное хранится в свойстве validated прошла покупка проверку или нет.


Итог


По существу буквально в 2 метода можно проверить покупку. Репозиторий с полным кодом доступен по ссылке https://github.com/denjoygroup/inapppurchase (автор кода Алексей Геворкян)


Конечно, мы упустили очень много нюансов обработки покупки, которые стоит учитывать при работе с реальными покупками.


Есть два хороших сервиса, которые предоставляют сервис для проверки чеков: https://ru.adapty.io/ и https://apphud.com/. Но, во-первых, для некоторых категорий приложений нельзя передавать данные 3 стороне, а во-вторых, если вы хотите отдавать платный контент динамически при совершении пользователем покупки, то вам придется разворачивать свой сервер.


P.S.


Ну, и, конечно, самое важное в серверной разработке это масштабируемость и устойчивость. Если у вас большая аудитория пользователей и при этом сервер не способен выдерживать нагрузки, то лучше и не реализовывать проверку покупок самим, а отправлять запросы сразу в iTunnes Connect и в Google API, иначе ваши пользователи сильно расстроятся.

Подробнее..

Apple Grace Period и Billing Retry статусы при обработке чеков пользователей

09.09.2020 16:16:51 | Автор: admin

Привет. В этой статье мы поговорим про такую частую проблему, как ошибка оплаты в мобильных приложениях с подписочной моделью. Если взять средние данные из системы Qonversion, то 15-20% триалов переходят в Billing Issue. Из них около 15% возвращаются в платное состояние. Поддержка Grace Period позволит улучшить пользовательский опыт и повысить процент возврата в платное состояние.


План:


  • Как устроен Billing Retry?
  • Что такое Grace Period?
  • Продуктовые подходы для работы с Billing Retry

Как устроен Billing Retry?


Ошибки с оплатой являются одной из частых причин потери подписчиков.
Для наглядности, рассмотрим следующий пример.



За некоторое время до завершения очередного оплаченного периода, Apple совершает попытку списать средства со счёта пользователя. Если при этом возникает ошибка, пользователь переходит в состояние Billing Retry. В таком состоянии он может находиться до 60 дней. При этом Apple периодически совершает попытку произвести оплату за следующий период подписки. Всё это время доступ к приложению для пользователя закрыт. На примере видно, что через 10 дней после успешного совершения оплаты возобновляется цикл списания средств. Всё это усложняет расчёт LTV, а также приводит к ухудшению пользовательского опыта.


При проверке чека пользователя мы обнаружим поле is_in_billing_retry_period и expiration_intent:


"pending_renewal_info": [    {        "expiration_intent": "2",        "auto_renew_product_id": "product.99.trial.3d",        "original_transaction_id": "10000000306492965",        "is_in_billing_retry_period": "1",        "product_id": "product.99.trial.3d",        "auto_renew_status": "1"    }]

Эти поля сообщают нам о неудачной попытке списать средства.


Что такое Grace Period?


Для улучшения пользовательского опыта в течение периода, когда Apple не может совершить списание средств за следующий автовозобновляемый период, ваше приложение может предоставлять доступ к контенту. Для этого вам необходимо использовать Grace Period. Grace Period включается для авто-возобновляемых подписок Enable Billing Grace Period for Auto-Renewable Subscriptions, в этом случае пользователь сохраняет полный контроль над приложением, пока Apple пытается произвести оплату.


Продолжительность Grace Period зависит от продолжительности вашей подписки:



Давайте рассмотрим два примера:


Пример 1: успешное списание в течение Grace Period



За некоторое время до завершения первого автовозобновляемого периода, Apple совершает неудачную попытку списать средства. Такой результат при отключенном Grace Period означал был моментальный переход в состояние Billing Retry с заблокированным доступом к приложению. Однако при включённом Grace Period, начинается следующий автовозобновляемый период, а пользователь продолжает сохранять доступ к контенту. Через некоторое время Apple совершает успешное списание средств, таким образом существующий цикл оплаты не прерывается.


Пример 2: неудачное списание в течение Grace Period



Во втором примере Apple не удаётся списать средства в течение Grace Period, что приводит к изменению текущего цикла списаний, а пользователь переходит в статус Billing Retry с заблокированным доступом. Через некоторое время Apple удаётся произвести списание денежных средств, при этом запускается новый автовозобновляемый период со дня списания.


После включения Grace Period в настройках приложения, JSON response, который вы получаете при валидации чека будет содержать дополнительное поле grace_period_expires_date_ms, именно это поле и означает новый период завершения доступа к контенту в приложении.


"pending_renewal_info": [    {        "expiration_intent": "2",        "grace_period_expires_date": "2020-09-05 23:41:42 Etc/GMT",        "auto_renew_product_id": "product.99.trial.3d",        "original_transaction_id": "10000000306492965",        "is_in_billing_retry_period": "1",        "grace_period_expires_date_pst": "2020-09-05 16:41:42 America/Los_Angeles",        "product_id": "product.99.trial.3d",        "grace_period_expires_date_ms": "1599349302000",        "auto_renew_status": "1"    }]

Как только платеж будет успешно совершён, поле исчезнет из чека вместе с is_in_billing_retry_period. В этом случае актуальным по истечению срока действия подписки станет поле expires_date_ms
которое нужно брать уже из receipt.in_app


"in_app": [            {                "quantity": "1",                "product_id": "product.99.trial.3d",                "transaction_id": "0000000306492966",                "original_transaction_id": "0000000306492965",                "purchase_date": "2020-08-25 02:53:10 Etc/GMT",                "purchase_date_ms": "1598323990000",                "purchase_date_pst": "2020-08-24 19:53:10 America/Los_Angeles",                "original_purchase_date": "2020-08-25 02:53:12 Etc/GMT",                "original_purchase_date_ms": "1598323992000",                "original_purchase_date_pst": "2020-08-24 19:53:12 America/Los_Angeles",                "expires_date": "2020-09-25 02:53:10 Etc/GMT",                "expires_date_ms": "1601002390000",                "expires_date_pst": "2020-09-24 19:53:10 America/Los_Angeles",                "web_order_line_item_id": "000000003253190",                "is_trial_period": "false",                "is_in_intro_offer_period": "false"            }        ]

Если платёж не будет успешно совершен после истечения 60 дней, поля is_in_billing_retry_period и grace_period_expires_date исчезнут из чека, и можно будет использовать expires_date_ms, однако в этом случае подписка будет истёкшей, а поле auto_renew_status будет равно 0


Продуктовые подходы для работы с Billing Retry и Grace Period


При обнаружении проблемы с оплатой рекомендуется сообщать об ошибке пользователю непосредственно внутри приложения, направив на страницу изменения настроек оплаты.


Для этого вам необходимо обрабатывать рецепт на серверной стороне исходя из изменений выше или использовать готовые решения.


Qonversion.checkPermissions { (permissions, error) in  if let error = error {    // handle error    return  }  if let premium = permissions["premium"], premium.isActive {    switch premium.renewState {       case .billingIssue:         // Grace period: permission is active, but there was some billing issue.         // Prompt the user to update the payment method.         break       default: break    }  }}
Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru