Программирование

Подключаем RuStore In-App Payments в React Native / Expo: полный гайд с разбором подводных камней

RuStore — российский магазин приложений от VK, который с 2022 года стал основной альтернативой Google Play для публикации Android-приложений в России. Если вы продаёте что-то в мобильном приложении на российском рынке, интеграция RuStore Billing неизбежна. Официальная документация есть, но она не предупреждает о ряде нетривиальных проблем, с которыми вы столкнётесь. Эта статья — концентрат практического опыта, полученного при написании приложения ReturnMaster.


Стек: React Native 0.76, Expo SDK 53, EAS Build, TypeScript.


1. Предварительные требования

Перед тем как писать код:

  1. Зарегистрируйте приложение в RuStore Console. Получите console_app_id — он понадобится в android/app/src/main/AndroidManifest.xml.
  2. Создайте in-app продукты в консоли (раздел «Монетизация → In-App покупки»). Задайте productId, тип (NON_CONSUMABLE / CONSUMABLE / SUBSCRIPTION) и цену.
  3. Опубликуйте хотя бы одну версию приложения — без этого тестирование покупок в sandbox не работает корректно.
  4. Установите на тестовое устройство RuStore с аккаунтом, добавленным в тестировщики консоли.

2. Добавление SDK

SDK поставляется через Maven-репозиторий VK Partner.

android/build.gradle

allprojects {
    repositories {
        // ...существующие репозитории...
        maven { url = uri("https://artifactory-external.vkpartner.ru/artifactory/maven") }
    }
}

android/app/build.gradle

dependencies {
    // ...
    implementation("ru.rustore.sdk-wrapper.react-native:pay:10.1.0")
}

Проверяйте актуальную версию в репозитории VK Partner.

android/app/src/main/java/<package>/MainApplication.kt

Зарегистрируйте пакет SDK:

import ru.rustore.react.pay.RuStoreReactPayPackage

class MainApplication : Application(), ReactApplication {
    override val reactNativeHost = object : DefaultReactNativeHost(this) {
        override fun getPackages() = PackageList(this).packages.apply {
            add(RuStoreReactPayPackage())
        }
        // ...
    }
}

3. Нативные конфиги

Это самое насыщенное подводными камнями место.

android/app/src/main/AndroidManifest.xml

Добавьте три блока внутрь тега <application>:

<!-- ID приложения в консоли RuStore -->
<meta-data
    android:name="console_app_id_value"
    android:value="@string/CONSOLE_APPLICATION_ID" />

<!-- URI-схема для возврата в приложение после банковской оплаты.
     ВАЖНО: используйте литерал, а не @string/ ссылку (объяснение в разделе 10.2) -->
<meta-data
    android:name="sdk_pay_scheme_value"
    android:value="your-app-scheme" />

<!-- Intent-filter для перехвата диплинка после возврата из банка -->
<intent-filter>
    <action android:name="android.intent.action.VIEW" />
    <data android:scheme="rustore" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
</intent-filter>

android/app/src/main/res/values/strings.xml

<string name="CONSOLE_APPLICATION_ID">2063708686</string>

android/gradle.properties

newArchEnabled=false

Важно: RuStore Pay SDK 10.x не поддерживает New Architecture. Если оставить newArchEnabled=true, приложение скомпилируется, но платежи работать не будут.


4. EAS Build: всё должно быть в Config Plugin

Если вы используете EAS Build, папка android/ генерируется с нуля при каждом билде через expo prebuild. Любые ручные изменения в android/ теряются. Всё перечисленное выше нужно реализовать через Expo Config Plugin.

plugins/withRuStore.js

const {
  withProjectBuildGradle,
  withAppBuildGradle,
  withAndroidManifest,
} = require('@expo/config-plugins')

function withRuStoreMaven(config) {
  return withProjectBuildGradle(config, (mod) => {
    // android/build.gradle
    const gradle = mod.modResults.contents
    if (!gradle.includes('artifactory-external.vkpartner.ru')) {
      mod.modResults.contents = gradle.replace(
        /maven\s*\{\s*url\s*=?\s*uri\("https:\/\/www\.jitpack\.io"\)\s*\}/,
        (match) => match + '\n        maven { url = uri("https://artifactory-external.vkpartner.ru/artifactory/maven") }'
      )
    }
    return mod
  })
}

function withRuStoreDependency(config) {
  return withAppBuildGradle(config, (mod) => {
    // android/app/build.gradle
    if (!mod.modResults.contents.includes('rustore.sdk-wrapper.react-native:pay')) {
      mod.modResults.contents = mod.modResults.contents.replace(
        /dependencies\s*\{/,
        'dependencies {\n    implementation("ru.rustore.sdk-wrapper.react-native:pay:10.1.0")'
      )
    }
    return mod
  })
}

function withRuStoreManifest(config) {
  return withAndroidManifest(config, (mod) => {
    // android/app/src/main/AndroidManifest.xml
    const app = mod.modResults.manifest.application[0]

    // console_app_id_value
    if (!app['meta-data']?.find((m) => m.$['android:name'] === 'console_app_id_value')) {
      app['meta-data'] = [...(app['meta-data'] ?? []), {
        $: { 'android:name': 'console_app_id_value', 'android:value': '@string/CONSOLE_APPLICATION_ID' }
      }]
    }

    // sdk_pay_scheme_value — литерал, не @string/ (см. раздел 10.2)
    if (!app['meta-data']?.find((m) => m.$['android:name'] === 'sdk_pay_scheme_value')) {
      app['meta-data'] = [...(app['meta-data'] ?? []), {
        $: { 'android:name': 'sdk_pay_scheme_value', 'android:value': 'your-app-scheme' }
      }]
    }

    // intent-filter для диплинка rustore://
    const mainActivity = app.activity?.find((a) =>
      a['intent-filter']?.some((f) =>
        f.action?.some((ac) => ac.$['android:name'] === 'android.intent.action.MAIN')
      )
    )
    if (mainActivity && !mainActivity['intent-filter']?.some((f) =>
      f.data?.some((d) => d.$['android:scheme'] === 'rustore')
    )) {
      mainActivity['intent-filter'] = [...(mainActivity['intent-filter'] ?? []), {
        action: [{ $: { 'android:name': 'android.intent.action.VIEW' } }],
        category: [
          { $: { 'android:name': 'android.intent.category.DEFAULT' } },
          { $: { 'android:name': 'android.intent.category.BROWSABLE' } },
        ],
        data: [{ $: { 'android:scheme': 'rustore' } }],
      }]
    }

    return mod
  })
}

module.exports = function withRuStore(config) {
  config = withRuStoreMaven(config)
  config = withRuStoreDependency(config)
  config = withRuStoreManifest(config)
  // withRuStoreStrings — добавляет CONSOLE_APPLICATION_ID в strings.xml
  // withRuStoreMainApplication — регистрирует RuStoreReactPayPackage в MainApplication.kt
  // withRuStoreDisableNewArch — пишет newArchEnabled=false в gradle.properties
  return config
}

app.json

{
  "expo": {
    "scheme": "your-app-scheme",
    "plugins": ["./plugins/withRuStore"]
  }
}

5. TypeScript-интерфейс нативного модуля

Нативный модуль называется RuStoreReactPaySDKModule (установлено через javap анализ DEX). Интерфейс — нетривиальный, документируем его полностью.

src/services/billing/ruStoreApi.ts

import { NativeModules } from 'react-native'

interface PurchaseResult {
  purchaseId?: string
  productId?: string
  invoiceId?: string   // для банковских (асинхронных) платежей
  orderId?: string
}

interface RuStorePurchaseInner {
  productId: string
  status: string        // 'CONFIRMED' | 'PAID' | 'CANCELLED' | 'CONSUMED' | ...
  purchaseId: string
  productType: string   // 'NON_CONSUMABLE_PRODUCT' | 'CONSUMABLE_PRODUCT' | ...
  purchaseTime?: string
  invoiceId?: string
  orderId?: string
  sandbox?: boolean
}

// Каждый элемент массива обёрнут в { productPurchase: { ... } }
// Это не опечатка — SDK действительно возвращает именно такую структуру
interface RuStorePurchase {
  productPurchase: RuStorePurchaseInner
}

interface IRuStoreReactPaySDKModule {
  purchase(
    productId: string,
    orderId: string | null,
    quantity: string | null,
    developerPayload: string | null,
    appUserId: string | null,
    appUserEmail: string | null,
    preferredPurchaseType: string | null,
    sdkTheme: string | null,
  ): Promise<PurchaseResult>

  getPurchases(
    productType: string | null,
    purchaseStatus: string | null,
  ): Promise<RuStorePurchase[]>

  getPurchaseAvailability(): Promise<{ isAvailable: boolean }>
}

export function getRuStoreApi(): IRuStoreReactPaySDKModule | null {
  const api = (NativeModules as Record<string, unknown>).RuStoreReactPaySDKModule
  return (api as IRuStoreReactPaySDKModule) ?? null
}

6. Проверка доступности

Прежде чем инициировать покупку, убедитесь что RuStore установлен и пользователь авторизован:

// src/services/billing/ruStoreProvider.ts
async function isRuStoreAvailable(): Promise<boolean> {
  const api = getRuStoreApi()
  if (!api) return false           // SDK не зарегистрировался (нет в NativeModules)
  try {
    const result = await api.getPurchaseAvailability()
    return result.isAvailable
  } catch {
    return false
  }
}

getPurchaseAvailability() вернёт false если:

  • RuStore не установлен на устройстве
  • Пользователь не авторизован в RuStore
  • Нет сетевого соединения

7. Покупка

// src/services/billing/ruStoreProvider.ts
type BillingError = 'cancelled' | 'already_owned' | 'unavailable' | 'unknown'

interface PurchaseOutcome {
  success: boolean
  error?: BillingError
}

async function purchaseProduct(productId: string): Promise<PurchaseOutcome> {
  const api = getRuStoreApi()
  if (!api) return { success: false, error: 'unavailable' }

  try {
    const result = await api.purchase(productId, null, null, null, null, null, null, null)

    // purchaseId — синхронная оплата (карта, кошелёк RuStore)
    // invoiceId  — асинхронная оплата через банк (Тинькофф, Сбер и др.)
    //              SDK возвращает invoiceId БЕЗ purchaseId, платёж подтверждается позже
    if (result?.purchaseId || result?.invoiceId) {
      return { success: true }
    }
    return { success: false, error: 'unknown' }

  } catch (e: unknown) {
    const err = e as { code?: string; message?: string }
    const code = String(err?.code ?? '').toLowerCase()
    const msg  = String(err?.message ?? '').toLowerCase()

    if (code.includes('cancel') || msg.includes('cancel')) {
      return { success: false, error: 'cancelled' }
    }
    if (code.includes('already') || code.includes('paid') || msg.includes('already')) {
      return { success: false, error: 'already_owned' }
    }
    return { success: false, error: 'unknown' }
  }
}

Вызов api.purchase() открывает нативную шторку RuStore поверх приложения. Пользователь выбирает способ оплаты и завершает платёж. Промис резолвится когда шторка закрывается.


8. Банковская оплата и диплинк

Это наиболее нетривиальная часть. Когда пользователь выбирает оплату через банковское приложение (Тинькофф, Сбербанк и др.), SDK:

  1. Открывает банковское приложение / веб-страницу оплаты
  2. После завершения возвращает пользователя по диплинку your-app-scheme://rustore/sdkPay/back
  3. SDK перехватывает этот интент нативно и резолвит JS-промис purchase()

Expo Router при этом независимо пытается обработать открывшийся URL и может выдать ошибку «no route found». Нужно добавить экран-обработчик:

app/rustore/sdkPay/back.tsx

import { useEffect } from 'react'
import { View, ActivityIndicator } from 'react-native'
import { router } from 'expo-router'

export default function RuStorePaybackScreen() {
  useEffect(() => {
    // SDK уже обработал интент нативно и резолвил промис purchase() в paywall.
    // Этот экран только предотвращает "route not found" и выполняет навигацию.
    setTimeout(() => {
      router.replace('/(tabs)')
    }, 800) // небольшая задержка, чтобы SDK успел завершить обработку
  }, [])

  return (
    <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
      <ActivityIndicator size="large" />
    </View>
  )
}

Схема URI (your-app-scheme) должна совпадать:

  • sdk_pay_scheme_value в android/app/src/main/AndroidManifest.xml
  • scheme в app.json

9. Получение списка покупок и восстановление

// src/services/billing/ruStoreProvider.ts
async function getPurchasedProductIds(): Promise<string[]> {
  const api = getRuStoreApi()
  if (!api) return []

  try {
    // ВАЖНО: передавайте тип продукта явно.
    // getPurchases(null, null) в ряде версий SDK возвращает пустой массив
    // вместо "все типы" — это задокументированная несогласованность.
    const purchases = await api.getPurchases('NON_CONSUMABLE', null)

    return purchases
      // Каждый элемент обёрнут в { productPurchase: { ... } }
      // Поле называется status, а НЕ purchaseState — несмотря на то что
      // в официальной документации упоминается purchaseState
      .filter((p) =>
        p.productPurchase.status === 'CONFIRMED' ||
        p.productPurchase.status === 'PAID'
      )
      .map((p) => p.productPurchase.productId)
  } catch (e) {
    console.error('[Billing] getPurchases failed:', e)
    return []
  }
}

Стратегия кэширования

Запросы к getPurchases — сетевые. Делайте их при старте приложения и кэшируйте результат локально (AsyncStorage, MMKV). Это позволит:

  • Не блокировать запуск UI ожиданием ответа сервера
  • Корректно работать в оффлайне
// src/services/billing/billingService.ts
const CACHE_KEY = 'purchased_features'

async function initialize() {
  // 1. Читаем кэш — UI разблокируется немедленно
  const cached = await AsyncStorage.getItem(CACHE_KEY)
  if (cached) {
    const ids: string[] = JSON.parse(cached)
    ids.forEach(id => enableFeature(id))
  }

  // 2. Синхронизируем с сервером
  if (await isRuStoreAvailable()) {
    const ids = await getPurchasedProductIds()
    if (ids.length > 0) {
      await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(ids))
      ids.forEach(id => enableFeature(id))
    }
    // Если ids пустой — не затираем кэш: возможно, кратковременная недоступность
  }
}

10. Подводные камни

10.1 ApplicationSchemeWasNotProvided при банковской оплате

Симптом: шторка RuStore открывается нормально, но при нажатии на банковский способ оплаты — крэш с сообщением:

RuStorePaymentException$ApplicationSchemeWasNotProvided:
Application scheme was not provided. Set app_scheme_value into AndroidManifest.

Ловушка: сообщение говорит app_scheme_value, но реальный ключ, который читает SDK — sdk_pay_scheme_value. Это разные meta-data с разным назначением.

Что происходит внутри (установлено анализом DEX):

// RuStorePayContentProvider.getInternalConfig() — псевдокод
val scheme = appInfo.metaData.getString("sdk_pay_scheme_value") // КРИТИЧНО
// Если scheme == null → выбрасывает ApplicationSchemeWasNotProvided
// SDK строит redirect URL: scheme + "://rustore/sdkPay/back"

Решение: добавить в android/app/src/main/AndroidManifest.xml под <application>:

<meta-data android:name="sdk_pay_scheme_value" android:value="your-app-scheme" />

10.2 Почему sdk_pay_scheme_value нужно указывать литералом

metaData.getString("sdk_pay_scheme_value") читает Bundle, заполненный из <meta-data> в android/app/src/main/AndroidManifest.xml. Если указать android:value="@string/MY_SCHEME", Android теоретически должен разрешить ссылку, но на практике поведение зависит от того, как именно SDK обращается к Bundle. Безопасный вариант — всегда указывать литерал:

<!-- android/app/src/main/AndroidManifest.xml -->

<!-- Безопасно -->
<meta-data android:name="sdk_pay_scheme_value" android:value="your-app-scheme" />

<!-- Работает, но рискованно -->
<meta-data android:name="sdk_pay_scheme_value" android:value="@string/APP_SCHEME" />

Для console_app_id_value — наоборот, @string/ работает нормально: там SDK использует metaData.getSerializable(), который корректно разрешает ресурсные ссылки.

10.3 getPurchases(null, null) возвращает пустой массив

Симптом: покупка проходит, фича разблокируется. После переустановки приложения «Восстановить покупки» говорит «Покупок не найдено».

Причина: getPurchases(null, null) с null в качестве productType в SDK 10.x не всегда возвращает NON_CONSUMABLE продукты. Поведение с null зависит от версии SDK и неконсистентно.

Почему покупка при этом работает: при покупке фича добавляется в память и кэш (AsyncStorage) до вызова getPurchases. При переустановке кэш стирается — и именно тогда обнаруживается, что getPurchases(null, null) всегда возвращал пустой список.

Решение: в src/services/billing/ruStoreProvider.ts всегда передавать тип явно:

// Не работает надёжно:
api.getPurchases(null, null)

// Работает:
api.getPurchases('NON_CONSUMABLE', null)

10.4 Структура ответа getPurchases не совпадает с документацией

Симптом: фильтрация по p.purchaseState === 'CONFIRMED' никогда не срабатывает, список покупок всегда пустой — даже при правильно работающей покупке.

Причина: официальная документация описывает поле purchaseState, но реальный ответ SDK выглядит так:

[
  {
    "productPurchase": {
      "productId": "your-product-id",
      "status": "CONFIRMED",
      "purchaseId": "...",
      "productType": "NON_CONSUMABLE_PRODUCT"
    }
  }
]

Два расхождения с документацией:

  1. Данные обёрнуты в { productPurchase: { ... } } — нет прямого доступа к полям на верхнем уровне
  2. Поле называется status, а не purchaseState

Правильный код в src/services/billing/ruStoreProvider.ts:

purchases
  .filter(p => p.productPurchase.status === 'CONFIRMED' || p.productPurchase.status === 'PAID')
  .map(p => p.productPurchase.productId)

Чтобы обнаружить это быстро — всегда логируйте сырой ответ при отладке:

const purchases = await api.getPurchases('NON_CONSUMABLE', null)
console.warn('[Billing] raw response:', JSON.stringify(purchases))

10.5 New Architecture несовместима

RuStore Pay SDK 10.x использует нативные мосты старой архитектуры React Native и не совместим с New Architecture (Fabric / TurboModules). При newArchEnabled=true модуль не регистрируется в NativeModules.

# android/gradle.properties
newArchEnabled=false

Если у вас Expo SDK 51+, где New Architecture включена по умолчанию, это нужно явно отключить.

10.6 Кэш не затирать при пустом ответе сервера

При инициализации в src/services/billing/billingService.ts не перезаписывайте локальный кэш пустым ответом:

// Неправильно — затрёт кэш если RuStore временно недоступен
const ids = await getPurchasedProductIds()
await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(ids))

// Правильно
const ids = await getPurchasedProductIds()
if (ids.length > 0) {
  await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(ids))
}

Пустой ответ может означать недоступность RuStore, а не отсутствие покупок.


11. Отладка

Проверить что SDK зарегистрировался

В Metro-логах при старте должно быть:

[RuStore] RuStoreReactPaySDKModule found OK

Если строки нет — пакет не зарегистрирован в android/app/src/main/java/<package>/MainApplication.kt.

Проверить android/app/src/main/AndroidManifest.xml в собранном APK

# Вытащить APK с устройства
adb shell pm path com.your.package
# → package:/data/app/.../base.apk
adb pull /data/app/.../base.apk ./app.apk

# Прочитать бинарный AndroidManifest
aapt dump xmltree app.apk AndroidManifest.xml | grep -A2 "sdk_pay_scheme"

Sandbox-тестирование

Покупки в sandbox не списывают реальные деньги. Для sandbox нужно:

  1. Войти в RuStore под аккаунтом, добавленным в тестировщики в консоли
  2. При покупке выбрать тестовую карту (RuStore предложит её автоматически в режиме sandbox)

Обратите внимание: в getPurchases для sandbox-покупок приходит "sandbox": true — это нормально. Фильтрация по статусу работает одинаково для реальных и sandbox-покупок.


12. Итоговая архитектура

Рекомендуемая структура сервиса для работы с покупками:

src/services/billing/
├── billingProvider.ts    # интерфейс BillingProvider (для тестирования можно подменить)
├── ruStoreProvider.ts    # реализация для RuStore
├── mockProvider.ts       # заглушка для разработки без RuStore
└── billingService.ts     # бизнес-логика: кэш, восстановление, проверка фич

src/services/billing/billingProvider.ts — интерфейс:

interface BillingProvider {
  isAvailable(): Promise<boolean>
  purchaseProduct(productId: string): Promise<PurchaseOutcome>
  getPurchasedProductIds(): Promise<string[]>
}

Такая абстракция позволяет разрабатывать UI без реального RuStore (через src/services/billing/mockProvider.ts) и легко мигрировать при выходе новой версии SDK.


Чеклист перед релизом

  • [ ] console_app_id_value в android/app/src/main/AndroidManifest.xml указывает на правильный ID из консоли
  • [ ] sdk_pay_scheme_value в android/app/src/main/AndroidManifest.xml задан литералом (не через @string/)
  • [ ] scheme в app.json совпадает с sdk_pay_scheme_value
  • [ ] newArchEnabled=false в android/gradle.properties
  • [ ] Добавлен маршрут-обработчик app/rustore/sdkPay/back.tsx для диплинка
  • [ ] В src/services/billing/ruStoreProvider.tsgetPurchases вызывается с явным productType, а не null
  • [ ] В src/services/billing/ruStoreProvider.ts: фильтрация по p.productPurchase.status, а не p.purchaseState
  • [ ] В src/services/billing/billingService.ts: кэш не затирается при пустом ответе сервера
  • [ ] Протестирована покупка в sandbox
  • [ ] Протестировано восстановление покупок после переустановки
  • [ ] Протестирована банковская оплата (асинхронный сценарий с диплинком)

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

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