PayApp SDK Integration — эквайринг на Centerm K10

Статус

Нативный модуль собран, APK компилируется. Требуется тестирование на реальном K10.

Что сделано

1. Expo Native Module (modules/payapp-bridge/)

Полный Kotlin bridge для PayApp SDK (PBF Group). Expo Module API (Kotlin DSL).

Структура:

modules/payapp-bridge/
├── package.json
├── expo-module.config.json
├── index.ts                          # JS entry: exports
├── src/
│   ├── PayAppBridge.types.ts         # TS типы
│   ├── PayAppBridgeModule.ts         # requireNativeModule('PayAppBridge')
│   └── usePayAppRoute.ts            # React hook: подписка на Route events
└── android/
    ├── build.gradle
    ├── src/main/
    │   ├── AndroidManifest.xml
    │   └── java/expo/modules/payappbridge/
    │       ├── PayAppBridgeModule.kt   # Expo Module DSL: Name, Events, AsyncFunction
    │       ├── PayAppManager.kt        # Singleton: PayAppDevice lifecycle + Terminal
    │       ├── RouteEventEmitter.kt    # HeadlessUiAdapter → JS events
    │       ├── ResponseHandlerImpl.kt  # onDone → resolve/reject Promise
    │       └── AmountConverter.kt      # kopecks → BigDecimal rubles
    └── libs/                           # 13 AAR/JAR файлов
        ├── crj-api.jar
        ├── crj-ecr.aar
        ├── devicecommon-release.aar
        ├── hexkeyboard-release.aar
        ├── lib_pinpad-release.aar
        ├── lib_socket-release.aar
        ├── lib_usb-release.aar
        ├── usbserial.aar
        ├── volna_lib-release.aar
        ├── face-pay-norecognition.aar
        ├── adaptiveui.aar
        └── centerm/
            ├── centerm_hal-release.aar
            └── core-centerm-noBio-release.aar

JS API:

PayAppBridgeModule.initialize(): Promise<void>
PayAppBridgeModule.isAvailable(): Promise<boolean>
PayAppBridgeModule.startPayment(amountKopecks, inn?): Promise<PayAppPaymentResult>
PayAppBridgeModule.startRefund(amountKopecks, rrn?, inn?): Promise<PayAppPaymentResult>
PayAppBridgeModule.startCancel(amountKopecks, trxId?, rrn?): Promise<PayAppPaymentResult>
PayAppBridgeModule.interruptTransaction(): Promise<void>
PayAppBridgeModule.closeBatch(): Promise<void>
 
// Events:
"onRouteChange"     → { type, message?, title?, amount?, qrString? }
"onInitStateChange" → { state: string }

2. Payment abstraction layer (src/payment/)

Абстракция эквайринга, отделённая от FiscalCoreClient:

src/payment/
├── PaymentClient.ts       # интерфейс: processCardPayment, interruptPayment, closeBatch
├── PayAppPaymentClient.ts # реализация через PayAppBridgeModule
├── StubPaymentClient.ts   # dev-стаб (2 сек delay, fake RRN)
└── index.ts               # авто-определение: real или stub

Интерфейс:

interface PaymentClient {
  isAvailable(): Promise<boolean>;
  processCardPayment(req: PaymentRequest): Promise<PaymentResult>;
  interruptPayment(): Promise<void>;
  closeBatch(): Promise<void>;
}

3. PaymentScreen — обновлённый flow

Card flow:

  1. paymentClient.processCardPayment({ amount }) → ждём SDK
  2. Если APPROVED → ordersApi.submit({ ..., rrn, payment_method: "card" })
  3. Переход на ResultScreen с RRN

Cash flow:

  1. ordersApi.submit({ ..., payment_method: "cash" }) → без эквайринга
  2. Переход на ResultScreen

Processing UI:

  • usePayAppRoute() hook показывает текущее состояние SDK
  • cardReading → “Приложите карту” + сумма + QR-код (СБП)
  • showInfo → сообщение от банка
  • Кнопка “Отмена” → interruptPayment()

4. Конфигурация

app.json — добавлен "NFC" в permissions.

orders.ts — добавлено rrn?: string в SubmitOrderRequest.

metro.config.jsmodules/ в watchFolders.

tsconfig.jsonmodules/**/* в include.

Build configuration (критично!)

android/ генерируется expo prebuild

Файлы android/build.gradle и android/gradle.properties перезаписываются при каждом expo prebuild. Изменения ниже нужно повторно применять или автоматизировать.

android/gradle.properties — добавить/изменить:

# PayApp SDK (core-centerm-noBio-release.aar) requires minSdk 30; K10 is API 30
android.minSdkVersion=30
 
# PayApp SDK AARs compiled with Kotlin 2.2.0 — need Kotlin 2.1+ compiler
android.kotlinVersion=2.1.20
 
# Disable RN Gradle plugin JDK alignment — KotlinTopLevelExtension is interface in Kotlin 2.1+
react.internal.disableJavaVersionAlignment=true
 
# Align JVM target for Java and Kotlin (needed when disableJavaVersionAlignment=true)
kotlin.jvm.target.validation.mode=IGNORE

android/build.gradle — изменить:

  1. Pinned kotlin-gradle-plugin version (строка в dependencies {} блока buildscript):
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${kotlinVersion}")
  1. JVM target alignment (после apply plugin: "com.facebook.react.rootproject"):
subprojects { subproject ->
    subproject.plugins.whenPluginAdded { plugin ->
        if (plugin.class.name == 'org.jetbrains.kotlin.gradle.plugin.KotlinAndroidPluginWrapper' ||
            plugin.class.name == 'org.jetbrains.kotlin.gradle.plugin.KotlinPluginWrapper') {
            subproject.tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
                compilerOptions {
                    jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
                }
            }
        }
    }
}

Node.js

Требуется Node 20

Node 25 ломает simdjson, Node 22 ломает TypeScript stripping в node_modules. Для prebuild и build использовать:

PATH="/opt/homebrew/opt/node@20/bin:$PATH" npx expo prebuild --platform android
PATH="/opt/homebrew/opt/node@20/bin:$PATH" npx expo run:android

SDK API (проверено через javap)

Реальные классы и методы из AAR, выясненные декомпиляцией:

КлассПакетМетод
PayAppDeviceFactoryru.pbfgroup.payapp.core.apicreate(context): PayAppDevice
PayAppDeviceru.pbfgroup.payapp.core.apiinitialize() (void), setUiAdapter(UiAdapter), createEcrTerminal(ResponseHandler): Terminal, getInitState(): StateFlow
HeadlessUiAdapterru.pbfgroup.payapp.core.apiконструктор без аргументов, routes(): Flow<Route>
Terminalcom.ecr.crj.apistartPaymentTransaction(BigDecimal, String?), startRefundTransaction(BigDecimal, String?, String?), startCancelTransaction(BigDecimal, String?, String?), startCloseBatchTransaction(), interruptTransaction()
ResponseHandlerru.pbfgroup.payapp.core.ecronDone(TerminalResponseResult)

createEcrTerminal — минимальная сигнатура

Принимает только ResponseHandler. Могут быть перегрузки с terminalId, shopId, merchantId, ConnectionSettings — проверить на реальном устройстве. Текущая реализация вызывает минимальный вариант.

Что НЕ сделано / требует проверки

На реальном устройстве (K10):

  1. PayAppBridgeModule.initialize() — убедиться что onInitStateChange: Initialized приходит
  2. startPayment(10000) (100 руб) — карта → APPROVED + RRN
  3. Interrupt — нажать “Отмена” во время ожидания карты → INTERRUPTED
  4. Route events — проверить что onRouteChange корректно маппит типы (cardReading, showInfo)
  5. closeBatch — сверка итогов
  6. End-to-end — карта → RRN → ordersApi.submit → ResultScreen

createEcrTerminal параметры:

Текущая реализация вызывает dev.createEcrTerminal(responseHandler) — только с ResponseHandler. Если SDK требует дополнительные параметры (TMS connection settings, terminal ID) — нужно расширить вызов. Проверить на реальном устройстве или документацию SDK.

Автоматизация gradle-патчей:

android/ генерируется expo prebuild. Варианты:

  • Config plugin (Expo) — модифицирует build.gradle и gradle.properties программно
  • Post-prebuild скриптsed/patch после expo prebuild
  • Зафиксировать android/ — убрать из .gitignore, не запускать prebuild без нужды

Рекомендация

Написать Expo config plugin в modules/payapp-bridge/ который автоматически патчит Gradle-файлы. Это стандартный подход для Expo managed workflow.

Фискализация:

FiscalCoreClient остаётся стабом. Интеграция с ФЯ (api012) — отдельная задача. См. Fiscal Core Integration.

Типы TerminalResponseResult:

ResponseHandlerImpl.kt обрабатывает onDone(result). Конкретные подклассы TerminalResponseResult (Done.APPROVED, Done.DENIED, Error) — проверить на реальном устройстве, возможно потребуется расширить маппинг.

Архитектурные решения

PaymentClient отдельно от FiscalCoreClient

Почему: Эквайринг (авторизация карты, RRN) и фискализация (чеки, 54-ФЗ) — разные системы с разными failure modes. PayApp SDK отвечает только за эквайринг.

Kotlin 2.1.20

Почему: SDK AARs скомпилированы с Kotlin 2.2.0 (metadata version 2.2.0). Kotlin 1.9.x читает metadata до 2.0.0, Kotlin 2.0.x — до 2.1.0. Только Kotlin 2.1+ читает 2.2.0.

Побочный эффект: React Native 0.76 Gradle plugin ломается с Kotlin 2.1 (KotlinTopLevelExtension стал interface). Workaround: react.internal.disableJavaVersionAlignment=true + ручное выравнивание JVM target.

minSdk 30

Почему: core-centerm-noBio-release.aar объявляет minSdkVersion=30. K10 работает на Android 11 (API 30), так что ограничение не влияет на целевое устройство.

Suspend + Continuation pattern

Почему: SDK вызовы асинхронные — startPaymentTransaction() не возвращает результат напрямую, а вызывает ResponseHandler.onDone(). Bridge оборачивает это в Kotlin suspendCoroutine + continuation, Expo Module получает Promise.

Команды для сборки

# Переключиться на Node 20
export PATH="/opt/homebrew/opt/node@20/bin:$PATH"
 
# Генерация android/ (если нужно с нуля)
cd /Users/alekseymikhailov/IdeaProjects/erp-pos/mobile
npx expo prebuild --platform android
 
# После prebuild — применить gradle-патчи (см. секцию "Build configuration")
 
# Сборка и установка на устройство
npx expo run:android
 
# Dev-сервер (после первой сборки)
npx expo start --dev-client

Ссылки

  • Sale Flow — оркестрация продажи
  • Fiscal Core Integration — ФЯ api012
  • План: /Users/alekseymikhailov/.claude/plans/transient-giggling-wilkinson.md