Как использовать нативный платежный сервис SberPay в кроссплатформенном приложении на Flutter

от автора

Всем привет! Это Мурат Насиров и Артем Баркалов, мы Flutter-разработчики в Friflex. Разрабатываем высоконагруженные мобильные приложения для бизнеса и специализируемся на Flutter. В этой статье мы собрали большую часть кейсов, с которыми вы можете столкнуться при интеграции кнопки оплаты SberPay в приложении на Flutter. Это поможет вам понять механику работы СберПэй и шаги, которые необходимо сделать для передачи событий из натива во Flutter.

Содержание

  1. Подготовка

  2. Регистрация заказа в шлюзе Сбера

  3. Создание плагина

    3.1 Внедрение Android-части

    3.1.1 Интеграция через Maven репозиторий

    3.1.2 Интеграция вручную

    3.1.3 Настройка проекта

    3.1.4 Android-плагин и нативная кнопка

    3.2 Внедрение iOS-части

    3.2.1 iOS плагин

  4. Внедрение платформ в Flutter-приложение

Подготовка

Использование SberPay SDK в продакшене требует наличия установленного приложения «Сбербанк Онлайн» (СБОЛ), иначе в процессе оплаты произойдет ошибка. Тестирование можно провести либо на симуляторе iOS, либо на реальном устройстве iOS или Android. На эмуляторе Android тестирование не выйдет — при попытке оплаты возникает ошибка. Разработчики SDK используют аппаратные возможности смартфонов: геолокацию, bluetooth, Wi-Fi для снижения вероятности совершения мошеннических операций (условный антифрод).

Первым делом разработчик узнает от Сбербанка (по договору о подключении SberPay SDK в приложение), логин/пароль (credentials) и ссылку, которые указываются в файле build.gradle проекта (приложения) в части Android для получения библиотеки. Либо отдельно запрашивается aar-бандл. Для подключения SDK на iOS выдадут отдельную ссылку на репозиторий, хотя он публичный

Вместе с данными для получения SDK выдают тестовые данные для регистрации заказа в шлюзе Сбера, а также специальные apiKey и merchantLogin, которые используются для инициализации SberPay SDK в вашем приложении. Тестовые данные не подходят для проверки списания реальных денежных средств с реальной карты, они исключительно для тестирования. Для работы SberPay в проде клиент, в приложение которого интегрируется этот SDK, должен подписать договор с Сбербанком о получении этих данных. После чего, их можно использовать для совершения оплаты с реальной банковской карты.

Регистрация заказа в шлюзе Сбера

В документации описан процесс регистрации заказов в платежном шлюзе Сбера, откуда необходимо в случае успешного ответа получить значение ключа sbolBankInvoiceId. То есть при оформлении заказа бекенд вашего проекта должен отправить запрос с параметрами, которые указаны в документации, откуда затем получить sbolBankInvoiceId и передать его мобильному приложению. С помощью коллбэков, которые Сбер будет отсылать вашему бекенду, будет понятно, совершена оплата или нет.

Для тестовой регистрации заказа достаточно отправлять запросы через Postman (файл):

POST https://3dsec.sberbank.ru/payment/rest/register.do, где register.doодностадийная оплата:

{   "userName": "testUserName", // логин ЛК Сбера, выдается по договору   "password": "testPassword", // пароль ЛК Сбера, выдается по договору    "orderNumber": "e2574f1785324f1592d9029cb05adbbd", // уникальный номер заказа   "amount": 19900, // сумма к оплате в копейках   "returnUrl": "sbersdk://spay", // диплинк на приложение, возвращает к СДК   "jsonParams": {     "app2app": true, // Если true, в ответе придет sbolBankInvoiceId     "app.osType" : "android", // Тип ОС (можно всегда запрашивать с таким)     "app.deepLink": "sbersdk://spay" // диплинк на приложение, возвращает к СДК   } }

Пример ответа:

{     "orderId": "1a8fb4ab-fe19-7372-94b6-2deb29335df0",     "formUrl": "https://secure-payment-gateway.ru/payment/merchants/sbersafe_sberid/payment_ru.html?mdOrder=1a8fb4ab-fe19-7372-94b6-2deb29335df0",     "externalParams": {         "sbolInactive": "false",         "sbolBankInvoiceId": "72e48b040afb4483b0a8c13c77e7e6f2",         "sbolDeepLink": "sberpay://invoicing/v2?bankInvoiceId=72e48b040afb4483b0a8c13c77e7e6f2&operationType=app2app"     } }

Поля returnUrl и app.deeplink должны служить диплинком для перехода обратно в мобильное приложение после авторизации через Сбербанк (либо СБОЛ), однако по факту они нигде не используются. Тем не менее, лучше их указывать в таком формате и обязательно сообщать в беседе с разработчиками SDK, что будет использоваться диплинк формата <вашасхема>://spay. Его нужно зарегистрировать на бекенде Сбера. Дальше он пригодится в реализации iOS-части плагина.

Маленький нюанс, при попытке зарегистрировать заказ, все символы валидного sbolBankInvoiceId должны быть в нижнем регистре. Если по каким-то причинам во время оплаты через модальное окно СберПэя произошла именно ошибка (не отмена), тогда sbolBankInvoiceId становится использованным, и зарегистрировать заказ с таким же orderNumber не получится. Одним из решений может быть перевод заказа в статус ожидания оплаты на бекенде. После чего можно оплатить заказ другим способом: например, через эквайринг. На iOS и Android по-разному реализована реакция на отмену оплаты, об этом — подробности ниже.

Создание плагина

Для создания плагина на Flutter используется команда (документация):

flutter create --org plugin.sdk --template=plugin --platforms=android,ios sber_pay

Где:

  • plugin.sdk — путь до исполняемого файла плагина на Android-стороне;

  • --template=plugin — указание, что создаётся именно плагин;

  • sber_pay — название плагина.

Если вы решитесь отправить свой проект в удаленный репозиторий, следует обязательно сделать его приватным — ввиду наличия в коде секретных данных.

Внедрение Android-части

Минимальная версия Android SDK — 21.

Есть 2 пути интеграции SDK.

Структура папок Android

Структура папок Android

1) Интеграция через Maven репозиторий

В файле android/build.gradle, внутри android {...}, нужно найти dependencies и указать:

dependencies {    implementation( 'ru.spaymentsplus.libraries:spaysdk:1.2.4' ) { transitive = true } }

По договору вам передадут секретные данные для получения aar-бандла. Вообще, как сказали разработчики SDK, если вы планируете отправлять свое приложение в удаленный репозиторий, то достаточно сделать его приватным и давать доступ только команде. Если же вы работаете один, можно рассмотреть такой способ. 

Создаем файл sber_pay/example/android/sberpay.properties и указываем переменные:

sPayUrl=ссылка из договора для получения бандла sPayUsername=логин из договора для получения бандла sPayPassword=пароль из договора для получения бандла

В файле sber_pay/example/android/.gitignore указываем sberpay.properties как игнорируемый, чтобы он не попал в удаленный репозиторий.

Теперь переходим в build.gradle и получаем aar-бандлы по указанным в sberpay.properties данным:

def sberpayProperties = new File('sberpay.properties') def properties = new Properties() properties.load(sberpayProperties.newDataInputStream())  def link = properties.getProperty('sPayUrl') def login = properties.getProperty('sPayUsername') def pass = properties.getProperty('sPayPassword')  allprojects {    repositories {        google()        mavenCentral()        maven {            name = "GitHubPackages"            url = uri(link)            credentials {                username = login                password = pass            }        }    } }

Таким образом удастся получить библиотеки и при этом не раскрыть секретные логин/пароль/ссылку. Насколько удалось выяснить, получение бандлов таким способом вызвано тем, что SberPay SDK использует специальную библиотеку для профилирования, у которой есть требования к безопасности. Из-за этого этот SDK (по крайней мере на Android стороне) не может быть публичным.

2) Интеграция вручную

Получив aar-бандлы, создаем в папке android папку libs и перемещаем туда библиотеки. Затем в build.gradle подключаются эти библиотеки вместе с транзитивными зависимостями. Внутри android {...} нужно найти dependencies и указать

dependencies {    // .aar бандлы SberPay SDK    implementation files('../libs/bms-sdk-fingerprint_VERSION_release.aar')    implementation files('../libs/SDK-VERSION.aar')     // Транзитивные библиотеки, необходимые для работы SberPay SDK    implementation 'com.google.android.material:material:<version>'    implementation 'io.github.sberid:SberIdSDK:<version>'    implementation 'com.google.dagger:dagger:<version>'    implementation 'androidx.lifecycle:lifecycle-runtime-ktx:<version>' }

Подключаемые здесь aar-бандлы, если отправляются в контроль версий, должны храниться только в приватном репозитории.

Настройка проекта

После получения SberPay SDK нужно установить minSdkVersion 21 в sber_pay/android/build.gradle:

defaultConfig {    minSdkVersion 21 }

Проверьте, что в sber_pay/example/android/app/build.gradle также указана 21 версия:

defaultConfig {    …    minSdkVersion 21 }

Еще в приложении стоит добавить разрешения для доступа к геолокации в файле sber_pay/example/android/app/src/main/AndroidManifest.xml:

<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_UPDATES" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

Это увеличит шансы для совершения успешной оплаты.

Для работы с сервисом SberPay в релизе, если у вас включена обфускация необходимо добавить proguard. В android/app/build.gradle по умолчанию shrinkResources и minifyEnabled не указаны:

android {     ...     buildTypes {         release {             // shrinkResources и minifyEnabled не указаны, по-умолчанию true, однако лучше указать явно             shrinkResources true             minifyEnabled true             proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'         }     } } 

Теперь в android/app/proguard-rules.pro добавляем:

-keep class spay.sdk.** { *; }  -keepclassmembers,allowobfuscation class * {   @com.google.gson.annotations.SerializedName <fields>; }

Если вам не нужна обфускация, тогда shrinkResources и minifyEnabled в android/app/build.gradle должны быть false:

android {     ...     buildTypes {         release {             shrinkResources false             minifyEnabled false         }     } } 

Соответственно, создавать android/app/proguard-rules.pro в таком случае не нужно.

Android-плагин и нативная кнопка

Когда с получением библиотеки разобрались, можно приступать к внедрению нативной кнопки СберПэя. Как комментировали сами разработчики этого SDK, решение завязывать весь функционал оплаты на кнопке предстоит пересмотреть в будущих версиях.

В документации к СберПэю предлагают создать специальный PlatformView, который затем разворачивается на стороне Flutter-приложения. Делать это необязательно, в Flutter-части этот процесс будет описан подробнее.

Пока что настроим файл плагина sber_pay/android/src/main/kotlin/plugin/sdk/sber_pay/SberPayPlugin.kt:

Настройка SberPayPlugin.kt
/** * Плагин для оплаты с использованием SberPay. Для работы нужен установленный Сбербанк (либо Сбол). */ class SberPayPlugin : FlutterPlugin, MethodCallHandler, ActivityAware {     private lateinit var channel: MethodChannel    private lateinit var binding: FlutterPluginBinding    private lateinit var activity: Activity    private lateinit var context: Context     /** Кнопка для управления оплатой **/    private lateinit var button: SPayButton     override fun onAttachedToEngine(flutterPluginBinding: FlutterPluginBinding) {        channel = MethodChannel(flutterPluginBinding.binaryMessenger, "sber_pay")        binding = flutterPluginBinding        context = flutterPluginBinding.applicationContext        channel.setMethodCallHandler(this)    }     override fun onMethodCall(call: MethodCall, result: Result) {        when (call.method) {            // Инициализация            "init" -> {                initialize(call, result)            }            // Проверка готовности к оплате            "isReadyForSPaySdk" -> {                /**                 * Метод для проверки готовности к оплате.                 * Зависит от переданного аргумента [env] при инициализации через метод [initialize]                 * (см. комментарий к методу). Запрос может выполняться долго.                 *                 * @return Если у пользователя нет установленного сбера в режимах                 * SPayStage.SandboxRealBankApp, SPayStage.prod - вернет false.                 */                result.success(button.isReadyForSPaySdk())            }            // Оплата            "payWithBankInvoiceId" -> {                showSberPaymentModal(call, result)            }             else -> {                result.notImplemented()            }        }    }     /**     * Метод для оплаты, в аргументы которого обязательно необходимо передать:     * @property apiKey ключ, выдаваемый по договору, либо создаваемый в личном кабинете;     * @property merchantLogin логин, выдаваемый по договору, либо создаваемый в личном кабинете;     * @property appPackage пакет вашего приложения;     * @property language использовано по умолчанию "RU";     * @property bankInvoiceId параметр, который получаем после запроса для регистрации заказа в     * шлюзе Сбера.     */    private fun showSberPaymentModal(call: MethodCall, result: Result) {        val args = call.arguments as Map<*, *>        var responseSent = false // Флаг для отслеживания отправки ответа        var hasError = false // Флаг для отслеживания отправки ошибки         try {            val apiKey = args["apiKey"] as String            val merchantLogin = args["merchantLogin"] as String            val bankInvoiceId = args["bankInvoiceId"] as String            val appPackage = context.packageName            val language = "RU"             if (!responseSent) {                button.payWithBankInvoiceId(apiKey, merchantLogin, bankInvoiceId, appPackage, language) { response ->                    when (response) {                        // Оплата не завершена                        is PaymentResult.Processing ->                            result.success("processing")                        // Оплата прошла успешно                        is PaymentResult.Success ->                            result.success("success")                        // Оплата прошла с ошибкой                        is PaymentResult.Error -> {                            if (!hasError) {                                hasError = true                                if (response.merchantError is MerchantError.SdkClosedByUser) {                                    result.success("cancel")                                    return@payWithBankInvoiceId                                }                                result.error("-", "MerchantError", response.merchantError?.description                                        ?: "Ошибка выполнения оплаты")                                return@payWithBankInvoiceId                            }                        }                    }                    responseSent = true                }            }        } catch (error: Exception) {            result.error("-", error.localizedMessage, error.message)        }    }     /**     * Метод инициализации, выполняется перед стартом приложения.     * [env], полученный из FLutter, Тесты со всеми типами [env] лучше всего проводить на реальном     * устройстве. Он определяет тип запуска:     *     * @property SPayStage.SandboxRealBankApp устройство с установленным Сбером;     * @property SPayStage.SandBoxWithoutBankApp устройство без Сбера;     * @property SPayStage.prod устройство с установленным Сбером, работает с продовыми данными.     */    private fun initialize(call: MethodCall, result: Result) {        val args = call.arguments as Map<*, *>        val sPayStage = when (args["env"] as String) {            "sandboxRealBankApp" -> SPayStage.SandboxRealBankApp            "sandboxWithoutBankApp" -> SPayStage.SandBoxWithoutBankApp            else -> {                SPayStage.Prod            }        }        // Оплата частями        val enableBnpl = args["enableBnpl"] as Boolean? ?: false         try {            SPaySdkApp.getInstance().initialize(application = activity.application, stage = sPayStage, enableBnpl = enableBnpl)            result.success(true)        } catch (e: Exception) {            result.error("-", e.localizedMessage, e.message)        }    }     override fun onDetachedFromEngine(binding: FlutterPluginBinding) {        channel.setMethodCallHandler(null)    }     override fun onAttachedToActivity(activityBinding: ActivityPluginBinding) {        activity = activityBinding.activity        button = SPayButton(activity, null)    }     override fun onReattachedToActivityForConfigChanges(activityBinding: ActivityPluginBinding) {        activity = activityBinding.activity        button = SPayButton(activity, null)    }     override fun onDetachedFromActivity() {}     override fun onDetachedFromActivityForConfigChanges() {} }

Внедрение iOS-части

Минимальная версия iOS — 12.0, XCode 13

Начнем с подключения SDK. Для добавления нативного SDK необходимо перенести папку SPaySdk.xcframework в папку iOS-плагина. 

Для работы с плагином в приложении, в которое он будет интегрирован, необходимо добавить следующие параметры в файл sber_pay/example/ios/Runner/info.plist:

 <key>DTXAutoStart</key>     <string>false</string>     <key>LSApplicationQueriesSchemes</key>     <array>        <string>sbolidexternallogin</string>         <string>sberbankidexternallogin</string>     </array>    <key>NSAppTransportSecurity</key>       <dict>          <key>NSExceptionDomains</key>          <dict>             <key>gate1.spaymentsplus.ru</key>             <dict>                <key>NSExceptionAllowsInsecureHTTPLoads</key>                <true/>             </dict>             <key>ift.gate2.spaymentsplus.ru</key>             <dict>                <key>NSExceptionAllowsInsecureHTTPLoads</key>                <true/>             </dict>             <key>cms-res.online.sberbank.ru</key>                <dict>                    <key>NSExceptionAllowsInsecureHTTPLoads</key>                    <true/>                </dict>          </dict>       </dict>     <key>NSBluetoothAlwaysUsageDescription</key>     <string>Данные Bluetooth собираются и отправляются на сервер для безопасного проведения оплаты</string>     <key>NSBluetoothPeripheralUsageDescription</key>     <string>Данные Bluetooth собираются и отправляются на сервер для безопасного проведения оплаты</string>     <key>NSLocationWhenInUseUsageDescription</key>     <string>Данные локации необходимы для безопасного проведения оплаты</string> 

В отличие от Android, нужно добавить параметр для настройки диплинка на наше приложение, с помощью которого будет осуществлен возврат в наше приложение после перехода в Сбербанк онлайн/СБОЛ.

В Android данный функционал реализуется передачей параметра package. Если приложение до этого уже было настроено на работу с диплинками, можно использовать имеющуюся схему. Далее в реализации плагина будет дополнительно указана проверка на host в диплинке. В нашем случае переход обратно в приложение из приложения Сбербанка/СБОЛа будет по диплинку sbersdk://spay:

  <key>CFBundleURLTypes</key>      <array>         <dict>             <key>CFBundleTypeRole</key>             <string>Editor</string>             <key>CFBundleURLName</key>             <string>spay</string>             <key>CFBundleURLSchemes</key>             <array>             <string>sbersdk</string>             </array>         </dict>     </array>

Также можно добавить через вкладку Info→URL Types:

Настройка URL Types

Настройка URL Types

Далее необходимо добавить в Capabilities проекта Access wi-fi information. Для этого необходимо выбрать: Ваш таргет → Signing & Capabilities → +Capability → Access wi-fi information.

Настройка доступа к wi-fi

Настройка доступа к wi-fi

В части плагина, в sber_pay/ios/sber_pay.podspec необходимо добавить код:

s.preserve_paths = 'SPaySdk.xcframework/**/*' s.xcconfig = { 'OTHER_LDFLAGS' => '-framework SPaySdk' } s.vendored_frameworks = 'SPaySdk.xcframework'

В это же файле нужно установить iOS 12 версии:

s.platform = :ios, '12.0'

Соответственно на стороне приложения тоже должна быть iOS 12. Установить ее можно выбрав в таргете нужную версию в настройках Minimum deployments.

iOS плагин

Нативная реализация SDK на платформе iOS не привязана к кнопке, поэтому сразу приступим к реализации плагина. Настроим sber_pay/ios/Classes/SberPayPlugin.swift. Аналогично платформе Android можно не создавать нативную кнопку, а использовать только готовое API.

Настройка SberPayPlugin.swift
// Плагин для оплаты с использованием SberPay. public class SberPayPlugin: NSObject, FlutterPlugin {     public static func register(with registrar: FlutterPluginRegistrar) {        let channel = FlutterMethodChannel(name: "sber_pay", binaryMessenger: registrar.messenger())        let instance = SberPayPlugin()        registrar.addMethodCallDelegate(instance, channel: channel)        /// Создание [addApplicationDelegate] для перехода по диплинку обратно в приложение        registrar.addApplicationDelegate(instance)    }     public func application(_ app: UIApplication,open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {        /// Если при открытии приложения с диплинком если он содержит хост "spay", то такой диплинк        /// попадает в нативный плагин. Таким образом работает возврат в приложение и получение данных нативным        /// SDK от приложения Сбербанк онлайн/СБОЛ        if  url.host == "spay" {            SPay.getAuthURL(url)        }         return true    }     public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {        switch call.method {            /// Инициализация        case "init":            initialize(call, result:result)            /// Проверка готовности к оплате        case "isReadyForSPaySdk":            /**             Метод для проверки готовности к оплате.             Зависит от переданного аргумента [env] при инициализации через метод [initialize]             (см. комментарий к методу). Запрос может выполняться долго.              - Returns Если у пользователя нет установленного сбера в режимах SEnvironment.sandboxRealBankApp,             SEnvironment.prod - вернет false.             */            result(SPay.isReadyForSPay)            // Оплата        case "payWithBankInvoiceId":            payWithBankInvoiceId(call, result: result)        default:            result(FlutterMethodNotImplemented)        }    }     /**     Метод для оплаты, в аргументы которого обязательно необходимо передать:     - Parameter apiKey ключ, выдаваемый по договору, либо создаваемый в личном кабинете;     - Parameter merchantLogin логин, выдаваемый по договору, либо создаваемый в личном кабинете;     - Parameter bankInvoiceId параметр, который получаем после запроса для регистрации заказа в     шлюзе Сбера.     - Parameter redirectUri диплинк обратно в приложение после перехода в Сбербанк     */    private func payWithBankInvoiceId(_ call: FlutterMethodCall, result: @escaping FlutterResult) {        guard let args = call.arguments as? [String: Any],              let apiKey = args["apiKey"] as? String,              let merchantLogin = args["merchantLogin"] as? String,              let bankInvoiceId = args["bankInvoiceId"] as? String,              let redirectUri = args["redirectUri"] as? String        else {            result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid arguments", details: nil))            return        }         if bankInvoiceId.count != 32 {            result(FlutterError(code: "-", message: "MerchantError", details: "Длина bankInvoiceId должна быть 32 символа"))            return        }         guard let topController = getTopViewController() else {            result(FlutterError(code: "PluginError", message: "SberPay: Failed to implement controller", details: nil))            return        }         let request = SBankInvoicePaymentRequest(            merchantLogin: merchantLogin,            bankInvoiceId: bankInvoiceId,            language: "RU",            redirectUri: redirectUri,            apiKey: apiKey)         SPay.payWithBankInvoiceId(with: topController, paymentRequest: request) { state, info  in            switch state {            case .success:                result("success")            case .waiting:                result("processing")            case .cancel:                result("cancel")            case .error:                result(FlutterError(code: "-", message: "Ошибка оплаты", details: info))            @unknown default:                result(FlutterError(code: "-", message: "Неопределенная ошибка", details: info))            }        }    }     /**     Метод инициализации, выполняется перед стартом приложения.     [env], полученный из FLutter, Тесты со всеми типами [env] лучше всего проводить на реальном устройстве. Он     определяет тип запуска:      - Parameter SEnvironment.sandboxRealBankApp устройство с установленным Сбером;     - Parameter SEnvironment.sandboxWithoutBankApp устройство без Сбера;     - Parameter SEnvironment.prod устройство с установленным Сбером, работает с продовыми данными.     - Parameter enableBnpl Функционал Оплата частями     */    private func initialize(_ call: FlutterMethodCall, result: @escaping FlutterResult) {        guard let args = call.arguments as? [String: Any],              let env = args["env"] as? String        else {            result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid arguments", details: nil))            return        }         let enableBnpl = args["enableBnpl"] as? Bool ?? false         let sPayStage: SEnvironment        switch env {        case "sandboxRealBankApp":            sPayStage = .sandboxRealBankApp        case "sandboxWithoutBankApp":            sPayStage = .sandboxWithoutBankApp        default:            sPayStage = .prod        }         SPay.setup(bnplPlan: enableBnpl, environment: sPayStage)        result(true)    }     private func getTopViewController() -> UIViewController? {        var topController = UIApplication.shared.keyWindow?.rootViewController         while let presentedViewController = topController?.presentedViewController {            topController = presentedViewController        }         return topController    } }

Внедрение платформ в Flutter-приложение

Наконец можно приступать к написанию кода в Flutter-части. Ранее упоминалось, что можно использовать нативную кнопку оплаты из SberPay SDK через PlatformView. Однако лучше всего просто сверстать ее в Flutter. В случае чего ее можно будет легко отредактировать.

Обратите внимание, здесь мы создаем кнопку именно на стороне плагина, делается это потому, что нам нужно соответствовать гайдланам Сбера по дизайну и исключить редактирование кнопки. Тем не менее, фактически от нее не зависит функционал оплаты, что является плюсом. Поэтому сначала в корне плагина создаем папку assets, в которой добавим лого СберПэя и шрифты (их можно получить из гайдбука Сбера).

Структура папки assets

Структура папки assets

После чего в pubspec.yaml объявим созданные ассеты:

flutter:  assets:    - assets/sberpay_logo.png   fonts:    - family: SberPaySans      fonts:        - asset: assets/fonts/spay_sans_text.ttf

Теперь создадим кнопку СберПэя в папке lib плагина:

/// Виджет нативной кнопки оплаты Сбербанка class SberPayButton extends StatelessWidget {  const SberPayButton({    super.key,    this.onPressed,  });   /// Обработчик нажатия на кнопку  final VoidCallback? onPressed;   @override  Widget build(BuildContext context) {    return InkWell(      onTap: onPressed,      child: Container(        height: 48,        decoration: BoxDecoration(          color: const Color(0xFF21A038),          borderRadius: BorderRadius.circular(12),        ),        child: Row(          mainAxisAlignment: MainAxisAlignment.center,          children: [            const Flexible(              child: Text(                'Оплатить',                style: TextStyle(                  color: Colors.white,                  fontSize: 16,                  fontFamily: 'SberPaySans',                  package: 'sber_pay',                ),              ),            ),            const SizedBox(width: 8),            Image.asset(              'assets/sberpay_logo.png',              package: 'sber_pay',              width: 48,              height: 22,            ),          ],        ),      ),    );  } }

СберПэй позволяет использовать свой SDK в трех режимах:

  • prod — прод;

  • sandboxWithRealBank — песочница с банковским приложением;

  • sandboxWithoutBank — песочница.

Последние два режима применяются для тестирования процесса оплаты. Они нужны, чтобы предупредить возможные сложности с использованием СберПэя. Для каждого из режимов создадим enum, который будет определять режим запуска SberPay SDK:

/// Тип инициализации сервисов Сбербанка enum SberPayEnv {  /// Продуктовый режим.  ///  /// Для авторизации пользователя происходит редирект в приложение Сбербанка.  prod,  /// Режим песочницы.  ///  /// Позволяет протестировать оплату как в [prod], но с тестовыми данными.  /// Для авторизации пользователя происходит редирект в приложение Сбербанка.  ///  /// На Android тестируется только на реальных устройствах. На iOS можно  /// тестировать как на реальном устройстве, так и в симуляторе.  sandboxRealBankApp,  /// Режим песочницы без перехода в банк.  ///  /// При авторизации пользователя не осуществляется переход в приложение  /// Сбербанка.  ///  /// На Android тестируется только на реальных устройствах. На iOS можно  /// тестировать как на реальном устройстве, так и в симуляторе.  sandboxWithoutBankApp }

Добавим еще один специальный enum, который будет транслировать платформенные ответы в удобный вид:

/// Статусы оплаты enum SberPayPaymentStatus {  /// Успешный результат  success,   /// Необходимо проверить статус оплаты  processing,   /// Пользователь отменил оплату  cancel,   /// Неизвестный тип  unknown;   static fromString(String? value) {    switch (value) {      case "success":        return SberPayPaymentStatus.success;      case "processing":        return SberPayPaymentStatus.processing;      case "cancel":        return SberPayPaymentStatus.cancel;      default:        return SberPayPaymentStatus.unknown;    }  } }

Когда все это выполнено, можно приступать к написанию логики плагина. Flutter с новых версий использует немного другой подход в общении между платформами и фреймворком. В нашем случае будет применяться более упрощенный старый подход. Поэтому все сгенерированные платформенные файлы можно удалить, оставив лишь главный файл плагина.

sber_pay.dart
/// Плагин для отображения нативной кнопки SberPay SDK /// /// Все исключения (Exceptions) приходящие из методов этого класса должны /// обрабатываться уровнем выше. class SberPayPlugin {  static const methodChannel = MethodChannel('sber_pay');   /// Инициализация SberPay SDK.  ///  /// Необходимо выполнить для начала работы с библиотекой.  /// На платформе Android этот метод является асинхронным, однако у  /// него нет API (коллбека) для выполнения кода после завершения  /// инициализации.  ///  /// * [env] - среда запуска, которая определяется через [SberPayEnv].  /// * [enableBnpl] - функционал оплаты частями  static Future<bool> initSberPay({    required String env,    bool? enableBnpl,  }) async {    final result = await methodChannel.invokeMethod<bool>('init', {      'env': env,      'enableBnpl': enableBnpl,    });    return result ?? false;  }   /// Метод для проверки готовности к оплате.  ///  /// Зависит от переданного аргумента [env] при инициализации через метод  /// [initSberPay] (см. комментарий к методу).  ///  /// Запрос может выполняться долго, поэтому здесь стоит искусственная  /// задержка, чтобы дождаться инициализации SDK.  ///  /// Если у пользователя нет установленного сбера в режимах  /// [SberPayEnv.sandboxRealBankApp], [SberPayEnv.prod] - вернет false.  static Future<bool> isReadyForSPaySdk() async {    final result = await methodChannel.invokeMethod<bool>('isReadyForSPaySdk');    if (result == null || result == false) {      await Future.delayed(        const Duration(seconds: 2),        () async {          return await methodChannel.invokeMethod<bool>('isReadyForSPaySdk');        },      );    }    return result ?? false;  }   /// Метод оплаты через SberPay SDK.  /// * [apiKey] - ключ, выдаваемый по договору, либо создаваемый в личном  /// кабинете;  /// * [merchantLogin] - логин, выдаваемый по договору, либо создаваемый в  /// личном кабинете;  /// * [bankInvoiceId] - параметр, который получаем после запроса для  /// регистрации заказа в шлюзе Сбера.  /// * [redirectUri] - диплинк для перехода обратно в приложение после открытия  /// Сбербанка (только на iOS).  ///  /// Возвращает статус оплаты [SberPayPaymentStatus]  static Future<SberPayPaymentStatus> payWithBankInvoiceId({    required String apiKey,    required String merchantLogin,    required String bankInvoiceId,    required String redirectUri,  }) async {    final result = await methodChannel.invokeMethod<String>(      'payWithBankInvoiceId',      {        'apiKey': apiKey,        'merchantLogin': merchantLogin,        'bankInvoiceId': bankInvoiceId,        'redirectUri': redirectUri,      },    );    return SberPayPaymentStatus.fromString(result);  } }

В методе isReadyForSPaySdk стоит искусственная задержка, потому что метод initialized на стороне Android выполняется асинхронно, и нельзя дождаться его выполнения, так как в этом методе нет соответствующего API (например, коллбэка при его завершении). На iOS такой проблемы нет. Разработчики SDK обещали исправить это в будущих версиях.

Плагин полностью готов к работе. Теперь можно создать реализацию в sber_pay/example/lib/main.dart для тестирования SberPay SDK. Она может быть простенькая, а может быть как эта:

main.dart
/// Необходимо указать по данным из договора const _apiKey = ''; const _merchantLogin = '';  /// Диплинк на переход в приложение const _redirectUri = 'sbersdk://spay';  void main() => runApp(const SberPayExampleApp());  class SberPayExampleApp extends StatefulWidget {  const SberPayExampleApp({super.key});   @override  State<SberPayExampleApp> createState() => _SberPayExampleAppState(); }  class _SberPayExampleAppState extends State<SberPayExampleApp> {  late final TextEditingController _controller;  late String _paymentStatus;  late bool _isPluginLoading;  late bool _isAppReadyForPay;  late bool _isPluginInitialized;  late SberPayEnv _selectedInitType;  late Color _color;   @override  void initState() {    super.initState();    _controller = TextEditingController();    _paymentStatus = '';    _selectedInitType = SberPayEnv.sandboxWithoutBankApp;    _isPluginLoading = false;    _isAppReadyForPay = false;    _isPluginInitialized = false;    _color = Colors.grey;    _readyForPay();  }   Future<void> _readyForPay() async {    setState(() => _isPluginLoading = true);    _isPluginInitialized = await SberPayPlugin.initSberPay(      env: _selectedInitType.name,    );    if (mounted) setState(() {});     _isAppReadyForPay = await SberPayPlugin.isReadyForSPaySdk();    if (!_isAppReadyForPay) {      _isAppReadyForPay = await SberPayPlugin.isReadyForSPaySdk();    }    if (mounted) setState(() => _isPluginLoading = false);  }   void _setEnv(SberPayEnv env) {    _selectedInitType = env;    _paymentStatus = '';    _color = Colors.grey;    _readyForPay();  }   @override  void dispose() {    _controller.dispose();    super.dispose();  }   @override  Widget build(BuildContext context) {    return MaterialApp(      theme: ThemeData(        colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF21A038)),        useMaterial3: true,      ),      home: Scaffold(        appBar: AppBar(          title: const Text('SberPay plugin example'),          backgroundColor: const Color(0xFF21A038),          centerTitle: true,        ),        body: Builder(          builder: (context) {            return Padding(              padding: const EdgeInsets.all(8.0),              child: SingleChildScrollView(                child: Column(                  mainAxisSize: MainAxisSize.min,                  children: [                    Table(                        border: TableBorder.all(),                        defaultVerticalAlignment:                            TableCellVerticalAlignment.middle,                        children: [                          _tableRowWrapper(                            'Тип запуска',                            Column(                              children: [                                ChoiceChip(                                  label: const Text('Прод'),                                  selected:                                      _selectedInitType == SberPayEnv.prod,                                  onSelected: (_) => _setEnv(SberPayEnv.prod),                                ),                                const SizedBox(                                  width: 10,                                ),                                ChoiceChip(                                  label: const Text('Песочница/Банк'),                                  selected: _selectedInitType ==                                      SberPayEnv.sandboxRealBankApp,                                  onSelected: (_) => _setEnv(                                    SberPayEnv.sandboxRealBankApp,                                  ),                                ),                                const SizedBox(                                  width: 10,                                ),                                ChoiceChip(                                  label: const Text('Песочница'),                                  selected: _selectedInitType ==                                      SberPayEnv.sandboxWithoutBankApp,                                  onSelected: (_) => _setEnv(                                    SberPayEnv.sandboxWithoutBankApp,                                  ),                                ),                              ],                            ),                          ),                          _tableRowWrapper(                            'Плагин проинициализирован',                            Text(_isPluginLoading                                ? 'Загрузка'                                : _isPluginInitialized                                    ? "ДА"                                    : "НЕТ"),                          ),                          _tableRowWrapper(                            'Оплата доступна',                            Text(_isPluginLoading                                ? 'Загрузка'                                : _isAppReadyForPay                                    ? "ДА"                                    : "НЕТ"),                          ),                          _tableRowWrapper(                            'Статус операции оплаты',                            SizedBox(                              height: 80,                              child: Row(                                children: [                                  CircleAvatar(                                    backgroundColor: _color,                                    radius: 10.0,                                  ),                                  Flexible(                                    child: Text(                                      _paymentStatus.isEmpty                                          ? "Оплата не производилась"                                          : _paymentStatus,                                      textAlign: TextAlign.center,                                    ),                                  )                                ],                              ),                            ),                          ),                        ]),                    const SizedBox(height: 16),                    Padding(                      padding: const EdgeInsets.symmetric(horizontal: 16),                      child: TextField(                        controller: _controller,                        textInputAction: TextInputAction.done,                        decoration: InputDecoration(                          hintText: 'Введите bankInvoiceID',                          suffixIcon: GestureDetector(                            onTap: () {                              _controller.clear();                              _color = Colors.grey;                              setState(() => _paymentStatus = '');                            },                            child: const Icon(Icons.close, size: 32),                          ),                        ),                      ),                    ),                    const SizedBox(height: 16),                    Padding(                      padding: const EdgeInsets.symmetric(horizontal: 16),                      child: _isPluginLoading                          ? Container(                              height: 48,                              decoration: BoxDecoration(                                color: const Color(0xFF21A038),                                borderRadius: BorderRadius.circular(12),                              ),                              child: const Center(                                child: CircularProgressIndicator(                                  color: Colors.white,                                ),                              ),                            )                          : SberPayButton(                              onPressed: () async {                                if (_apiKey.isEmpty || _merchantLogin.isEmpty) {                                  ScaffoldMessenger.of(context).showSnackBar(                                    const SnackBar(                                      content: Text(                                        'Не заданы apiKey и/или merchantLogin',                                      ),                                      duration: Duration(seconds: 2),                                    ),                                  );                                  return;                                }                                if (_controller.text.isEmpty) {                                  ScaffoldMessenger.of(context).showSnackBar(                                    const SnackBar(                                      content: Text('Введите bankInvoiceID'),                                      duration: Duration(seconds: 2),                                    ),                                  );                                } else {                                  try {                                    final result = await SberPayPlugin                                        .payWithBankInvoiceId(                                      apiKey: _apiKey,                                      merchantLogin: _merchantLogin,                                      bankInvoiceId: _controller.text,                                      redirectUri: _redirectUri,                                    );                                    switch (result) {                                      case SberPayPaymentStatus.success:                                        _color = Colors.green;                                        _paymentStatus =                                            'Оплата прошла успешно';                                        break;                                      case SberPayPaymentStatus.processing:                                        _color = Colors.yellow;                                        _paymentStatus =                                            'Необходимо проверить статус оплаты';                                        break;                                      case SberPayPaymentStatus.cancel:                                        _color = Colors.blue;                                        _paymentStatus =                                            'Пользователь отменил оплату';                                        break;                                      case SberPayPaymentStatus.unknown:                                        _color = Colors.purple;                                        _paymentStatus =                                            'Неизвестное состояние';                                    }                                    setState(() {});                                  } on PlatformException catch (e) {                                    setState(                                      () {                                        _color = Colors.red;                                        _paymentStatus = e.details ?? '';                                      },                                    );                                  }                                }                              },                            ),                    ),                  ],                ),              ),            );          },        ),      ),    );  }   TableRow _tableRowWrapper(String title, Widget secondChild) {    return TableRow(      children: [        TableCell(child: Text(title, textAlign: TextAlign.center)),        Padding(          padding: const EdgeInsets.all(4.0),          child: Center(child: secondChild),        ),      ],    );  } }

Стоит учитывать, что это пример использования, и он не должен быть в продакшен коде 🙂

Для совершения оплаты в методе payWithBankInvoiceId указываются apiKey и merchantLogin, которые должны быть приватными. Конечно, их точно так же, как и credentials, для получения aar-бандла можно сразу указать здесь, и даже если отправлять в контроль версий, то только в приватный. Все же лучше получать эти данные из переменных среды, которые могут быть предварительно обфусцированы. Это повысит вероятность сохранения важных данных.

Также в этом методе, в аргументе redirectUri, указывается диплинк, который до этого мы регистрировали в iOS части приложения. Он понадобится для перехода из вашего приложения в приложение Сбербанка и обратно.

На Android и iOS по-разному реализована отмена оплаты (событие, когда пользователь свернул нативное модальное окно либо когда нажал «Отменить оплату»). Если в iOS приходит отдельное событие cancel, то в Android это приходит как ошибка.

После всех вышеизложенных трудов получается что-то такое:

Статья получилась очень насыщенной всякими нюансами. Когда смотришь на готовый результат, кажется, что ничего особенного в реализации нет. Однако все проекты создают люди, где-то могут быть ошибки, где-то могут быть особенности проектирования и многое другое. Поэтому получается то, что получается. И наверное, это нормально. Важно лишь поддерживать всегда стремление совершенствовать свои продукты и проекты, ведь из этого в целом и состоит разработка.

Финальный проект мы разместили в GitHub. Как обычно, делитесь своим мнением и наработками в данной области под этим постом.


ссылка на оригинал статьи https://habr.com/ru/articles/771354/


Комментарии

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

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