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