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