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