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

Node.JSGoogle APIМонетизация мобильных приложений
Tutorial

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

Теги:subscriptionsin-app purchasesin-app purchaseapireceiptvalidationappleiosdevelopmentandroiditunes connectgoogle api
Хабы: Node.JS Google API Монетизация мобильных приложений
+6
2,4k 50
Комментарии 4

Похожие публикации

Разработчик мобильных приложений
от 90 000 до 130 000 ₽FooddiКраснодарМожно удаленно
Mobile Developer (Android/iOS) - remote work
от 2 000 до 4 000 $datarocketsМожно удаленно
Team Lead мобильной разработки
от 150 000 ₽БИТ «Мастер»Ижевск
Mid/Senior iOS/macOS Developer (Swift)
от 120 000 до 150 000 ₽1312 Inc.СочиМожно удаленно
Senior QA Engineer in Automation
от 3 500 $WhiskМожно удаленно

Лучшие публикации за сутки