RuStore — российский магазин приложений от VK, который с 2022 года стал основной альтернативой Google Play для публикации Android-приложений в России. Если вы продаёте что-то в мобильном приложении на российском рынке, интеграция RuStore Billing неизбежна. Официальная документация есть, но она не предупреждает о ряде нетривиальных проблем, с которыми вы столкнётесь. Эта статья — концентрат практического опыта, полученного при написании приложения ReturnMaster.
Стек: React Native 0.76, Expo SDK 53, EAS Build, TypeScript.
1. Предварительные требования
Перед тем как писать код:
- Зарегистрируйте приложение в RuStore Console. Получите
console_app_id— он понадобится вandroid/app/src/main/AndroidManifest.xml. - Создайте in-app продукты в консоли (раздел «Монетизация → In-App покупки»). Задайте
productId, тип (NON_CONSUMABLE/CONSUMABLE/SUBSCRIPTION) и цену. - Опубликуйте хотя бы одну версию приложения — без этого тестирование покупок в sandbox не работает корректно.
- Установите на тестовое устройство 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:
- Открывает банковское приложение / веб-страницу оплаты
- После завершения возвращает пользователя по диплинку
your-app-scheme://rustore/sdkPay/back - 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.xmlschemeв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"
}
}
]
Два расхождения с документацией:
- Данные обёрнуты в
{ productPurchase: { ... } }— нет прямого доступа к полям на верхнем уровне - Поле называется
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 нужно:
- Войти в RuStore под аккаунтом, добавленным в тестировщики в консоли
- При покупке выбрать тестовую карту (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.ts:getPurchasesвызывается с явнымproductType, а неnull - [ ] В
src/services/billing/ruStoreProvider.ts: фильтрация поp.productPurchase.status, а неp.purchaseState - [ ] В
src/services/billing/billingService.ts: кэш не затирается при пустом ответе сервера - [ ] Протестирована покупка в sandbox
- [ ] Протестировано восстановление покупок после переустановки
- [ ] Протестирована банковская оплата (асинхронный сценарий с диплинком)


