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

—

от автора

Всем привет! Сегодня расскажу вам как развернуть сервер для проверки 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, иначе ваши пользователи сильно расстроятся.

ссылка на оригинал статьи https://habr.com/ru/post/527644/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *